8776 lines
438 KiB
JavaScript
8776 lines
438 KiB
JavaScript
/**
|
||
* DWPose Temporal Editor — pop-up GUI v4
|
||
* New in v4:
|
||
* - Catmull-Rom auto-smooth spline interpolation (with tension control)
|
||
* - Extended easing presets: back, elastic, expo, bounce
|
||
* - Per-segment easing (click tween line in dope sheet → popup)
|
||
* - Graph Editor tab: position curves for selected joint, draggable keyframe dots
|
||
* - Dope Sheet and Graph Editor on separate tabs
|
||
* - All v3 features preserved
|
||
*/
|
||
|
||
import { app } from "../../scripts/app.js";
|
||
|
||
// Three.js — loaded lazily for 3D orbit view
|
||
let THREE = null;
|
||
let _threeLoadCbs = [];
|
||
const _THREE_LOADING = import('https://esm.sh/three@0.169.0')
|
||
.then(m => { THREE = m.default ?? m; _threeLoadCbs.forEach(cb => cb()); _threeLoadCbs = []; })
|
||
.catch(() => console.warn('[DWPose] Three.js CDN unavailable — orbit falls back to 2D'));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Skeleton constants
|
||
// ---------------------------------------------------------------------------
|
||
const BODY_CONNECTIONS = [
|
||
[1,2],[1,5],[2,3],[3,4],[5,6],[6,7],
|
||
[1,8],[1,11],[8,9],[9,10],[11,12],[12,13],
|
||
[0,1],[0,14],[0,15],[14,16],[15,17],[10,19],[13,18],
|
||
];
|
||
// SMPL 24-joint connections for NLF overlay
|
||
// 0=Pelvis 1=L_Hip 2=R_Hip 3=Spine1 4=L_Knee 5=R_Knee 6=Spine2 7=L_Ankle 8=R_Ankle
|
||
// 9=Spine3 10=L_Foot 11=R_Foot 12=Neck 13=L_Collar 14=R_Collar 15=Head
|
||
// 16=L_Shoulder 17=R_Shoulder 18=L_Elbow 19=R_Elbow 20=L_Wrist 21=R_Wrist 22=L_Hand 23=R_Hand
|
||
const NLF_SMPL_CONNECTIONS = [
|
||
[0,1],[0,2],[0,3],[3,6],[6,9],[9,12],[12,15],
|
||
[1,4],[4,7],[7,10],[2,5],[5,8],[8,11],
|
||
[9,13],[9,14],[13,16],[14,17],[16,18],[17,19],[18,20],[19,21],[20,22],[21,23],
|
||
];
|
||
// BODY_CONNECTIONS order: [1,2],[1,5],[2,3],[3,4],[5,6],[6,7],[1,8],[1,11],[8,9],[9,10],[11,12],[12,13],[0,1],[0,14],[0,15],[14,16],[15,17],[10,19],[13,18]
|
||
const BONE_COLORS = [
|
||
"#0000ff","#00ff00","#0000ff","#0000ff","#00ff00","#00ff00", // arms
|
||
"#00ffff","#ff00ff","#00ffff","#00ffff","#ff00ff","#ff00ff", // torso + legs
|
||
"#ffff00","#c8c8c8","#c8c8c8","#969696","#969696", // nose-neck, face
|
||
"#00b4ff","#b400ff", // toes
|
||
];
|
||
const JOINT_COLORS = {
|
||
0:"#ffff00", 1:"#00ffff",
|
||
2:"#0000ff", 3:"#0000ff", 4:"#0000ff",
|
||
5:"#00ff00", 6:"#00ff00", 7:"#00ff00",
|
||
8:"#00ffff", 9:"#00ffff", 10:"#00ffff",
|
||
11:"#ff00ff",12:"#ff00ff",13:"#ff00ff",
|
||
14:"#c8c8c8",15:"#c8c8c8",16:"#969696",17:"#969696",
|
||
18:"#b400ff",19:"#00b4ff",
|
||
};
|
||
const RHAND_COLOR = "#ff6400";
|
||
const LHAND_COLOR = "#64c800";
|
||
const JOINT_LABELS = [
|
||
"NOSE","NECK","R_SHLDR","R_ELBOW","R_WRIST",
|
||
"L_SHLDR","L_ELBOW","L_WRIST","R_HIP","R_KNEE",
|
||
"R_ANKLE","L_HIP","L_KNEE","L_ANKLE","R_EYE",
|
||
"L_EYE","R_EAR","L_EAR","L_TOE","R_TOE",
|
||
];
|
||
const HAND_JOINT_LABELS = [
|
||
"Wrist",
|
||
"Thumb 1","Thumb 2","Thumb 3","Thumb 4",
|
||
"Index 1","Index 2","Index 3","Index 4",
|
||
"Mid 1", "Mid 2", "Mid 3", "Mid 4",
|
||
"Ring 1", "Ring 2", "Ring 3", "Ring 4",
|
||
"Pinky 1","Pinky 2","Pinky 3","Pinky 4",
|
||
];
|
||
const HAND_CONNECTIONS = [
|
||
[0,1],[1,2],[2,3],[3,4],
|
||
[0,5],[5,6],[6,7],[7,8],
|
||
[0,9],[9,10],[10,11],[11,12],
|
||
[0,13],[13,14],[14,15],[15,16],
|
||
[0,17],[17,18],[18,19],[19,20],
|
||
];
|
||
const R_WRIST = 4, L_WRIST = 7;
|
||
// Face: 70 2D landmarks (300W convention after dropping the spurious index-22 pt in extractor)
|
||
const N_FACE = 70;
|
||
const FACE_COLOR = "#ffd44c";
|
||
const FACE_JOINT_LABELS = Array.from({length: N_FACE}, (_, i) => `F${i}`);
|
||
// NLF body — 18 OpenPose joints (same indexing/labels as DWpose body 0–17, no toes 18/19)
|
||
const N_NLF_BODY = 18;
|
||
const NLF_BODY_COLOR = "#b478ff";
|
||
// Shared group→points accessor so views/hit-tests stay consistent as groups are added.
|
||
const _grpPts = (fd, g) => fd && (
|
||
g === "body" ? fd.body :
|
||
g === "rhand" ? fd.rhand :
|
||
g === "lhand" ? fd.lhand :
|
||
g === "face" ? fd.face :
|
||
g === "nlf_body" ? fd.nlf_body : null);
|
||
// Parse a joint label like "body_5" or "nlf_body_5" → { group, index }.
|
||
// Splits on the LAST underscore so multi-segment groups (nlf_body) work.
|
||
const _splitLabel = (lbl) => {
|
||
const us = String(lbl).lastIndexOf("_");
|
||
if (us < 0) return { group: lbl, index: -1 };
|
||
const idx = parseInt(lbl.slice(us + 1));
|
||
if (isNaN(idx)) return { group: lbl, index: -1 };
|
||
return { group: lbl.slice(0, us), index: idx };
|
||
};
|
||
// Reverse of SMPL_TO_OPENPOSE: OpenPose 18 joint → SMPL 24 joint index
|
||
const OP18_TO_SMPL = {0:15,1:12,2:17,3:19,4:21,5:16,6:18,7:20,8:2,9:5,10:8,11:1,12:4,13:7};
|
||
const ROW_H = 24;
|
||
const PANEL_HEADER_H = 22;
|
||
const VIEW_CYCLE = ["front","back","top","side","orbit","camera"];
|
||
const VIEW_LABELS = { front:"FRONT", back:"BACK", top:"TOP", side:"SIDE", orbit:"ORBIT", camera:"CAMERA" };
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared theme — keeps button heights, colors, and focus states consistent
|
||
// ---------------------------------------------------------------------------
|
||
const THEME = {
|
||
bgPanel: "#111420",
|
||
bgRow: "#13141f",
|
||
bgRowAlt: "#0f1019",
|
||
bgGroup: "#191f36",
|
||
bgBtn: "#1d2138",
|
||
bgBtnHover: "#252c44",
|
||
bgBtnDown: "#151930",
|
||
border: "#2c3352",
|
||
borderHov: "#4a68b0",
|
||
accent: "#5bc4ff",
|
||
text: "#d4ddf4",
|
||
textDim: "#7888a8",
|
||
textMute: "#424e6a",
|
||
danger: "#cc4b4b",
|
||
success: "#3aaa66",
|
||
btnH: 24, // unified button height (px)
|
||
};
|
||
|
||
/** Apply hover/focus/active states + a unified height. Idempotent.
|
||
* Hover/down use `filter: brightness(...)` so colored buttons retain their hue. */
|
||
function _styleButton(b, { bg = THEME.bgBtn, height = THEME.btnH } = {}) {
|
||
Object.assign(b.style, {
|
||
background: bg, border: `1px solid ${THEME.border}`,
|
||
color: THEME.text, borderRadius: "4px",
|
||
cursor: "pointer", fontSize: "12px",
|
||
height: `${height}px`, lineHeight: "1",
|
||
padding: "0 10px", boxSizing: "border-box",
|
||
transition: "filter 90ms ease, border-color 90ms ease",
|
||
outline: "none", filter: "brightness(1)",
|
||
});
|
||
b.addEventListener("mouseenter", () => { b.style.filter = "brightness(1.25)"; b.style.borderColor = THEME.borderHov; });
|
||
b.addEventListener("mouseleave", () => { b.style.filter = "brightness(1)"; b.style.borderColor = THEME.border; });
|
||
b.addEventListener("mousedown", () => { b.style.filter = "brightness(0.85)"; });
|
||
b.addEventListener("mouseup", () => { b.style.filter = "brightness(1.25)"; });
|
||
b.addEventListener("focus", () => { b.style.borderColor = THEME.accent; });
|
||
b.addEventListener("blur", () => { b.style.borderColor = THEME.border; });
|
||
}
|
||
|
||
// Default hand pose — pixel offsets from wrist (image y-down). Right hand; left mirrors x.
|
||
const DEFAULT_RHAND_PX = [
|
||
[ 0, 0], // 0 Wrist
|
||
[-15, -20], // 1 Thumb CMC
|
||
[-28, -38], // 2 Thumb MCP
|
||
[-37, -52], // 3 Thumb IP
|
||
[-44, -63], // 4 Thumb TIP
|
||
[-10, -52], // 5 Index MCP
|
||
[-10, -73], // 6 Index PIP
|
||
[-10, -85], // 7 Index DIP
|
||
[-10, -93], // 8 Index TIP
|
||
[ 0, -55], // 9 Middle MCP
|
||
[ 0, -78], // 10 Middle PIP
|
||
[ 0, -91], // 11 Middle DIP
|
||
[ 0,-100], // 12 Middle TIP
|
||
[ 10, -52], // 13 Ring MCP
|
||
[ 10, -73], // 14 Ring PIP
|
||
[ 10, -85], // 15 Ring DIP
|
||
[ 10, -92], // 16 Ring TIP
|
||
[ 20, -45], // 17 Pinky MCP
|
||
[ 20, -61], // 18 Pinky PIP
|
||
[ 20, -71], // 19 Pinky DIP
|
||
[ 20, -78], // 20 Pinky TIP
|
||
];
|
||
const DEFAULT_LHAND_PX = DEFAULT_RHAND_PX.map(([x,y])=>[-x,y]);
|
||
|
||
// Z-only rotation enabled; set false to bench entirely, true+expand axes later
|
||
const ROTATION_ENABLED = true;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Global interp-mode buttons (sidebar)
|
||
// ---------------------------------------------------------------------------
|
||
const INTERP_MODES = [
|
||
{ val: "catmull_rom", title: "Catmull-Rom (Auto-smooth spline)",
|
||
svg: `<svg width="26" height="16" viewBox="0 0 26 16" fill="none"><circle cx="3" cy="13" r="1.5" fill="currentColor"/><circle cx="10" cy="4" r="1.5" fill="currentColor"/><circle cx="17" cy="11" r="1.5" fill="currentColor"/><circle cx="24" cy="5" r="1.5" fill="currentColor"/><path d="M3,13 C5,6 7,1 10,4 C13,7 14,14 17,11 C20,8 22,7 24,5" stroke="currentColor" stroke-width="1.8"/></svg>` },
|
||
{ val: "constant", title: "Constant (Hold)",
|
||
svg: `<svg width="26" height="16" viewBox="0 0 26 16" fill="none"><polyline points="2,12 10,12 10,4 24,4" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>` },
|
||
{ val: "linear", title: "Linear",
|
||
svg: `<svg width="26" height="16" viewBox="0 0 26 16" fill="none"><line x1="2" y1="13" x2="24" y2="3" stroke="currentColor" stroke-width="2"/></svg>` },
|
||
{ val: "ease", title: "Ease (S-curve)",
|
||
svg: `<svg width="26" height="16" viewBox="0 0 26 16" fill="none"><path d="M2,13 C6,13 8,8 13,8 C18,8 20,3 24,3" stroke="currentColor" stroke-width="2"/></svg>` },
|
||
{ val: "ease_in", title: "Ease In",
|
||
svg: `<svg width="26" height="16" viewBox="0 0 26 16" fill="none"><path d="M2,13 Q24,13 24,3" stroke="currentColor" stroke-width="2"/></svg>` },
|
||
{ val: "ease_out", title: "Ease Out",
|
||
svg: `<svg width="26" height="16" viewBox="0 0 26 16" fill="none"><path d="M2,13 Q2,3 24,3" stroke="currentColor" stroke-width="2"/></svg>` },
|
||
];
|
||
|
||
// Per-segment popup preset grid (label, value)
|
||
const SEGMENT_PRESETS = [
|
||
["Global", null],
|
||
["Constant", "constant"], ["Linear", "linear"],
|
||
["Ease", "ease"], ["Ease In", "ease_in"], ["Ease Out", "ease_out"],
|
||
["Cubic In", "cubic_in"], ["Cubic Out", "cubic_out"], ["Cubic InOut", "cubic_inout"],
|
||
["Back In", "back_in"], ["Back Out", "back_out"], ["Back InOut", "back_inout"],
|
||
["Elastic Out", "elastic_out"], ["Expo Out", "expo_out"], ["Bounce Out", "bounce_out"],
|
||
["Catmull-Rom", "catmull_rom"],
|
||
];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Easing functions
|
||
// ---------------------------------------------------------------------------
|
||
function applyEasing(t, mode) {
|
||
switch (mode) {
|
||
case "constant": return 0;
|
||
case "linear": return t;
|
||
case "ease": return t * t * (3 - 2 * t);
|
||
case "ease_in": return t * t;
|
||
case "ease_out": return 1 - (1 - t) * (1 - t);
|
||
case "cubic_in": return t * t * t;
|
||
case "cubic_out": return 1 - (1 - t) ** 3;
|
||
case "cubic_inout": return t < 0.5 ? 4*t*t*t : 1 - (-2*t+2)**3 / 2;
|
||
case "back_in": return 2.70158*t*t*t - 1.70158*t*t;
|
||
case "back_out": return 1 + 2.70158*(t-1)**3 + 1.70158*(t-1)**2;
|
||
case "back_inout": { const c = 1.70158*1.525; return t<0.5 ? ((2*t)**2*((c+1)*2*t-c))/2 : ((2*t-2)**2*((c+1)*(2*t-2)+c)+2)/2; }
|
||
case "elastic_out": return t===0?0:t===1?1: Math.pow(2,-10*t)*Math.sin((t*10-0.75)*(2*Math.PI/3))+1;
|
||
case "expo_out": return t===1?1:1-Math.pow(2,-10*t);
|
||
case "bounce_out": {
|
||
const n=7.5625, d=2.75;
|
||
if (t<1/d) return n*t*t;
|
||
if (t<2/d) return n*(t-=1.5/d)*t+0.75;
|
||
if (t<2.5/d) return n*(t-=2.25/d)*t+0.9375;
|
||
return n*(t-=2.625/d)*t+0.984375;
|
||
}
|
||
default: return t;
|
||
}
|
||
}
|
||
|
||
// Catmull-Rom spline value: P0,P1,P2,P3 are scalar values, t in [0,1]
|
||
// alpha=0.5 is the classic "uniform" Catmull-Rom
|
||
function catmullRomScalar(P0, P1, P2, P3, t, alpha) {
|
||
const t2 = t*t, t3 = t2*t;
|
||
return alpha * ((2*P1) + (-P0+P2)*t + (2*P0-5*P1+4*P2-P3)*t2 + (-P0+3*P1-3*P2+P3)*t3);
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Three.js orbit view renderer — cylinders + spheres for DWPose skeleton
|
||
// ---------------------------------------------------------------------------
|
||
class ThreeOrbitRenderer {
|
||
constructor() {
|
||
this._r = null; // WebGLRenderer
|
||
this._s = null; // Scene
|
||
this._c = null; // PerspectiveCamera
|
||
this._sg = null; // Group holding skeleton meshes (cleared each frame)
|
||
this._ready = false;
|
||
if (THREE) this._setup();
|
||
else _threeLoadCbs.push(() => this._setup());
|
||
}
|
||
|
||
_setup() {
|
||
if (!THREE) return;
|
||
this._r = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
||
this._r.setPixelRatio(1);
|
||
this._r.setClearColor(0x000000, 0); // transparent — drawn on top of 2D bg
|
||
|
||
this._s = new THREE.Scene();
|
||
this._s.add(new THREE.AmbientLight(0xffffff, 0.5));
|
||
const key = new THREE.DirectionalLight(0xffffff, 0.88);
|
||
key.position.set(0.5, 1.5, 2); this._s.add(key);
|
||
const fill = new THREE.DirectionalLight(0x5577bb, 0.35);
|
||
fill.position.set(-1, -0.5, -1); this._s.add(fill);
|
||
|
||
this._sg = new THREE.Group();
|
||
this._s.add(this._sg);
|
||
this._c = new THREE.PerspectiveCamera(45, 1, 1, 200000);
|
||
this._ready = true;
|
||
}
|
||
|
||
get ready() { return this._ready; }
|
||
|
||
// outW/outH: output canvas dimensions — when provided, renders at that aspect and
|
||
// letterboxes within the panel. Used by camera view to show the true output framing.
|
||
render(ctx, vW, vH, fd, orbitYaw, orbitPitch, orbitZoom, poseW, poseH,
|
||
zGO, showAll, hiddenGroups, hiddenLayers, selJoint, lookAt,
|
||
outW = 0, outH = 0, dwposeAlpha = 1, boneScale = 1, nlfAlpha = 0) {
|
||
this._dwposeAlpha = dwposeAlpha;
|
||
this._boneScale = boneScale;
|
||
this._nlfAlpha = nlfAlpha;
|
||
if (!this._ready) return;
|
||
|
||
// Compute render rect (letterboxed when output aspect differs from panel)
|
||
let renderW = vW, renderH = vH;
|
||
this._c.aspect = vW / vH;
|
||
this._lastLetterbox = null;
|
||
|
||
if (outW > 0 && outH > 0) {
|
||
const outAspect = outW / outH;
|
||
const panelAspect = vW / vH;
|
||
if (outAspect > panelAspect) {
|
||
renderW = vW;
|
||
renderH = Math.round(vW / outAspect);
|
||
} else {
|
||
renderH = vH;
|
||
renderW = Math.round(vH * outAspect);
|
||
}
|
||
this._c.aspect = outAspect;
|
||
const lbX = Math.round((vW - renderW) / 2);
|
||
const lbY = Math.round((vH - renderH) / 2);
|
||
this._lastLetterbox = { x: lbX, y: lbY, w: renderW, h: renderH };
|
||
}
|
||
|
||
if (this._r.domElement.width !== renderW || this._r.domElement.height !== renderH)
|
||
this._r.setSize(renderW, renderH, false);
|
||
this._c.updateProjectionMatrix();
|
||
|
||
const lat = orbitPitch * Math.PI / 180;
|
||
const lon = orbitYaw * Math.PI / 180;
|
||
const dist = Math.max(poseW, poseH) * 1.4 / Math.max(0.01, orbitZoom);
|
||
const lx = lookAt?.x ?? 0, ly = lookAt?.y ?? 0, lz = lookAt?.z ?? 0;
|
||
this._c.position.set(
|
||
lx + Math.sin(lon) * Math.cos(lat) * dist,
|
||
ly + Math.sin(lat) * dist,
|
||
lz + Math.cos(lon) * Math.cos(lat) * dist,
|
||
);
|
||
this._c.up.set(0, 1, 0);
|
||
this._c.lookAt(lx, ly, lz);
|
||
|
||
this._buildSkeleton(fd, poseW, poseH, zGO, showAll, hiddenGroups, hiddenLayers, selJoint);
|
||
this._r.render(this._s, this._c);
|
||
if (this._lastLetterbox) {
|
||
const lb = this._lastLetterbox;
|
||
ctx.drawImage(this._r.domElement, lb.x, lb.y, lb.w, lb.h);
|
||
} else {
|
||
ctx.drawImage(this._r.domElement, 0, 0);
|
||
}
|
||
}
|
||
|
||
/** DWPose pixel-space → Three.js world (Y-up, Z toward viewer, centred at origin). */
|
||
_w(px, py, pz, poseW, poseH) {
|
||
return new THREE.Vector3(
|
||
px - poseW * 0.5,
|
||
-(py - poseH * 0.5),
|
||
pz * poseW * 0.35,
|
||
);
|
||
}
|
||
|
||
_buildSkeleton(fd, poseW, poseH, zGO, showAll, hiddenGroups, hiddenLayers, selJoint) {
|
||
this._sg.traverse(o => {
|
||
if (o.geometry) o.geometry.dispose();
|
||
if (o.material) {
|
||
(Array.isArray(o.material) ? o.material : [o.material]).forEach(m => m.dispose());
|
||
}
|
||
});
|
||
this._sg.clear();
|
||
if (!fd) return;
|
||
|
||
// Floor grid
|
||
const grid = new THREE.GridHelper(poseW * 2, 14, 0x2255aa, 0x0d1825);
|
||
const mats = Array.isArray(grid.material) ? grid.material : [grid.material];
|
||
mats.forEach(m => { m.transparent = true; m.opacity = 0.38; });
|
||
grid.scale.set(1, 1, 0.65);
|
||
this._sg.add(grid);
|
||
|
||
const body = fd.body || [];
|
||
const boneR = poseW * 0.013 * (this._boneScale ?? 1);
|
||
const bodyVis = !hiddenGroups.has("body");
|
||
const dwA = this._dwposeAlpha ?? 1;
|
||
|
||
// Body bones
|
||
if (bodyVis) {
|
||
for (let i = 0; i < BODY_CONNECTIONS.length; i++) {
|
||
const [a, b] = BODY_CONNECTIONS[i];
|
||
if (!body[a] || !body[b]) continue;
|
||
if (hiddenLayers.has(`body_${a}`) || hiddenLayers.has(`body_${b}`)) continue;
|
||
const conf = showAll ? 1 : Math.min(body[a][2]??1, body[b][2]??1);
|
||
if (conf < 0.01) continue;
|
||
const za = (body[a][3]??0) + (zGO[`body_${a}`]??0);
|
||
const zb = (body[b][3]??0) + (zGO[`body_${b}`]??0);
|
||
const m = this._cyl(
|
||
this._w(body[a][0], body[a][1], za, poseW, poseH),
|
||
this._w(body[b][0], body[b][1], zb, poseW, poseH),
|
||
boneR, BONE_COLORS[i] || '#666', conf * dwA);
|
||
if (m) this._sg.add(m);
|
||
}
|
||
}
|
||
|
||
// Body joints
|
||
if (bodyVis) {
|
||
for (let i = 0; i < body.length; i++) {
|
||
const pt = body[i]; if (!pt) continue;
|
||
if (hiddenLayers.has(`body_${i}`)) continue;
|
||
const conf = showAll ? 1 : (pt[2]??1); if (conf < 0.01) continue;
|
||
const z = (pt[3]??0) + (zGO[`body_${i}`]??0);
|
||
const isSel = selJoint?.group === "body" && selJoint?.index === i;
|
||
this._sg.add(this._sph(
|
||
this._w(pt[0], pt[1], z, poseW, poseH),
|
||
isSel ? boneR * 1.9 : boneR * 1.2,
|
||
isSel ? '#ffd700' : (JOINT_COLORS[i] || '#fff'), conf * dwA));
|
||
}
|
||
}
|
||
|
||
// Hands
|
||
const rWZ = (body[R_WRIST]?.[3]??0) + (zGO[`body_${R_WRIST}`]??0);
|
||
const lWZ = (body[L_WRIST]?.[3]??0) + (zGO[`body_${L_WRIST}`]??0);
|
||
if (fd.rhand && !hiddenGroups.has("rhand"))
|
||
this._hand(fd.rhand, rWZ, boneR * 0.5, RHAND_COLOR, 'rhand', poseW, poseH, zGO, hiddenLayers, showAll, selJoint, dwA);
|
||
if (fd.lhand && !hiddenGroups.has("lhand"))
|
||
this._hand(fd.lhand, lWZ, boneR * 0.5, LHAND_COLOR, 'lhand', poseW, poseH, zGO, hiddenLayers, showAll, selJoint, dwA);
|
||
|
||
// Face landmarks — points only; 70 pts with bones gets visually noisy.
|
||
if (fd.face && !hiddenGroups.has("face")) {
|
||
const faceR = boneR * 0.28;
|
||
const headZ = (body[0]?.[3] ?? 0) + (zGO[`body_0`] ?? 0); // default anchor = nose Z
|
||
const face = fd.face;
|
||
for (let i = 0; i < face.length; i++) {
|
||
const pt = face[i]; if (!pt) continue;
|
||
if (hiddenLayers.has(`face_${i}`)) continue;
|
||
const conf = showAll ? 1 : (pt[2] ?? 1); if (conf < 0.01) continue;
|
||
const z = pt[3] ?? headZ;
|
||
const isSel = selJoint?.group === "face" && selJoint?.index === i;
|
||
this._sg.add(this._sph(
|
||
this._w(pt[0], pt[1], z, poseW, poseH),
|
||
isSel ? faceR * 1.8 : faceR,
|
||
isSel ? '#ffd700' : FACE_COLOR, conf * dwA));
|
||
}
|
||
}
|
||
|
||
// NLF body — renders here in Three.js (matches DWpose's perspective-projected scale).
|
||
// Opacity is controlled by the NLF Overlay slider (this._nlfAlpha) instead of dwA.
|
||
const nlfA = this._nlfAlpha ?? 0;
|
||
if (nlfA > 0 && fd.nlf_body && fd.nlf_body.length && !hiddenGroups.has("nlf_body")) {
|
||
const _safeZ = (z) => {
|
||
const v = z ?? 0;
|
||
if (!isFinite(v) || Math.abs(v) > 1.5) return 0;
|
||
return v;
|
||
};
|
||
const nlf = fd.nlf_body;
|
||
for (let i = 0; i < BODY_CONNECTIONS.length; i++) {
|
||
const [a, b] = BODY_CONNECTIONS[i];
|
||
if (a >= nlf.length || b >= nlf.length) continue;
|
||
if (!nlf[a] || !nlf[b]) continue;
|
||
const conf = showAll ? 1 : Math.min(nlf[a][2] ?? 1, nlf[b][2] ?? 1);
|
||
if (conf < 0.05) continue;
|
||
if (hiddenLayers.has(`nlf_body_${a}`) || hiddenLayers.has(`nlf_body_${b}`)) continue;
|
||
const m = this._cyl(
|
||
this._w(nlf[a][0], nlf[a][1], _safeZ(nlf[a][3]), poseW, poseH),
|
||
this._w(nlf[b][0], nlf[b][1], _safeZ(nlf[b][3]), poseW, poseH),
|
||
boneR * 0.85, NLF_BODY_COLOR, conf * nlfA);
|
||
if (m) this._sg.add(m);
|
||
}
|
||
for (let i = 0; i < nlf.length; i++) {
|
||
const pt = nlf[i]; if (!pt) continue;
|
||
if (hiddenLayers.has(`nlf_body_${i}`)) continue;
|
||
const conf = showAll ? 1 : (pt[2] ?? 1); if (conf < 0.05) continue;
|
||
const isSel = selJoint?.group === "nlf_body" && selJoint?.index === i;
|
||
this._sg.add(this._sph(
|
||
this._w(pt[0], pt[1], _safeZ(pt[3]), poseW, poseH),
|
||
isSel ? boneR * 1.9 : boneR * 1.15,
|
||
isSel ? '#ffd700' : NLF_BODY_COLOR, conf * nlfA));
|
||
}
|
||
}
|
||
}
|
||
|
||
_hand(kps, wristZ, boneR, color, pfx, poseW, poseH, zGO, hiddenLayers, showAll, selJoint, dwA = 1) {
|
||
for (const [a, b] of HAND_CONNECTIONS) {
|
||
if (!kps[a] || !kps[b]) continue;
|
||
if (hiddenLayers.has(`${pfx}_${a}`) || hiddenLayers.has(`${pfx}_${b}`)) continue;
|
||
const conf = showAll ? 1 : Math.min(kps[a][2]??1, kps[b][2]??1);
|
||
if (conf < 0.01) continue;
|
||
const zA = kps[a][3] ?? wristZ, zB = kps[b][3] ?? wristZ;
|
||
const m = this._cyl(
|
||
this._w(kps[a][0], kps[a][1], zA, poseW, poseH),
|
||
this._w(kps[b][0], kps[b][1], zB, poseW, poseH),
|
||
boneR, color, conf * dwA);
|
||
if (m) this._sg.add(m);
|
||
}
|
||
for (let i = 0; i < kps.length; i++) {
|
||
if (!kps[i] || hiddenLayers.has(`${pfx}_${i}`)) continue;
|
||
const conf = showAll ? 1 : (kps[i][2]??1); if (conf < 0.01) continue;
|
||
const z = kps[i][3] ?? wristZ;
|
||
const isSel = selJoint?.group === pfx && selJoint?.index === i;
|
||
this._sg.add(this._sph(
|
||
this._w(kps[i][0], kps[i][1], z, poseW, poseH),
|
||
isSel ? boneR * 1.6 : boneR * 1.0,
|
||
isSel ? '#ffd700' : color, conf * dwA));
|
||
}
|
||
}
|
||
|
||
_cyl(pA, pB, radius, colorStr, opacity = 1) {
|
||
const ax = pB.x - pA.x, ay = pB.y - pA.y, az = pB.z - pA.z;
|
||
const len = Math.sqrt(ax*ax + ay*ay + az*az);
|
||
if (len < 0.5) return null;
|
||
const geo = new THREE.CylinderGeometry(radius * 0.65, radius, len, 8, 1);
|
||
const mat = new THREE.MeshPhongMaterial({ color: colorStr, shininess: 55,
|
||
opacity, transparent: opacity < 0.99 });
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.position.set((pA.x+pB.x)*0.5, (pA.y+pB.y)*0.5, (pA.z+pB.z)*0.5);
|
||
const ux = ax/len, uy = ay/len, uz = az/len; // unit bone dir
|
||
if (Math.abs(uy) > 0.9999) {
|
||
if (uy < 0) mesh.rotation.z = Math.PI;
|
||
} else {
|
||
// setFromUnitVectors(Y-axis → bone direction)
|
||
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), new THREE.Vector3(ux,uy,uz));
|
||
}
|
||
return mesh;
|
||
}
|
||
|
||
_sph(pos, radius, colorStr, opacity = 1) {
|
||
const geo = new THREE.SphereGeometry(radius, 10, 8);
|
||
const mat = new THREE.MeshPhongMaterial({ color: colorStr, shininess: 70,
|
||
opacity, transparent: opacity < 0.99 });
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.position.copy(pos);
|
||
return mesh;
|
||
}
|
||
|
||
dispose() {
|
||
if (!this._r) return;
|
||
this._sg?.traverse(o => {
|
||
if (o.geometry) o.geometry.dispose();
|
||
if (o.material) (Array.isArray(o.material) ? o.material : [o.material]).forEach(m => m.dispose());
|
||
});
|
||
this._r.dispose(); this._r = null; this._s = null; this._c = null;
|
||
this._sg = null; this._ready = false;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TemporalEditorOverlay
|
||
// ---------------------------------------------------------------------------
|
||
class TemporalEditorOverlay {
|
||
// Debug trace — POST user actions to /magos-debug/user-action when the node's
|
||
// debug_log widget is on.
|
||
_isDebugOn() {
|
||
try {
|
||
const node = app.graph.getNodeById(parseInt(this.nodeId));
|
||
const w = node?.widgets?.find(w => w.name === "debug_log");
|
||
return !!(w && w.value);
|
||
} catch (_) { return false; }
|
||
}
|
||
_logAction(action, payload) {
|
||
if (!this._isDebugOn()) return;
|
||
try {
|
||
fetch("/magos-debug/user-action", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ node: "Editor-JS", action, payload: payload || {} }),
|
||
keepalive: true,
|
||
}).catch(() => {});
|
||
} catch (_) {}
|
||
}
|
||
|
||
constructor(nodeId) {
|
||
this.nodeId = String(nodeId);
|
||
this.frames = {};
|
||
this.overrides = {};
|
||
this.zGlobalOffset = {}; // per-label additive Z shift applied across all frames
|
||
this.tweens = {}; // {fi: {label: mode_string}} per-segment easing
|
||
this.smoothWindow = 0;
|
||
this.interpolationMode = "catmull_rom";
|
||
this.catmullTension = 0.5;
|
||
this.frameCount = 0;
|
||
this.poseW = 512;
|
||
this.poseH = 512;
|
||
|
||
this.currentFrame = 0;
|
||
this.selectedJoint = null;
|
||
this.dragJoint = null;
|
||
this.dragGizmo = null;
|
||
this.gizmoCenter = null;
|
||
this.cameraView = "front";
|
||
|
||
this.playState = "stopped";
|
||
this.loopMode = "cycle"; // "cycle" | "pingpong" | "off"
|
||
this._playDir = null; // effective direction during ping-pong
|
||
this.playFPS = 24;
|
||
this._playRAF = null;
|
||
this._playLastT = null;
|
||
|
||
this.activeTab = "dope"; // "dope" | "graph"
|
||
this.expandedGroups = new Set(["body", "nlf_body"]);
|
||
|
||
// Graph editor state
|
||
this.graphViewport = { frameStart: 0, frameEnd: 100, valMin: 0, valMax: 512 };
|
||
this.graphDrag = null;
|
||
this._graphDragSel = null; // rubber-band rect selection in graph editor
|
||
this.graphShowX = true;
|
||
this.graphShowY = true;
|
||
this._segPopup = null;
|
||
|
||
// 3D Orbit camera
|
||
this.orbitYaw = -20; // degrees, horizontal rotation
|
||
this.orbitPitch = 15; // degrees, vertical tilt
|
||
this.orbitZoom = 1.0;
|
||
this._orbitDrag = null; // {startX, startY, startYaw, startPitch}
|
||
|
||
// Front view pan / zoom
|
||
this.vpZoom = 1.0;
|
||
this.vpPanX = 0;
|
||
this.vpPanY = 0;
|
||
this._vpPanDrag = null; // {startX, startY, startPanX, startPanY}
|
||
|
||
// Multi-select on dope sheet
|
||
this.selKfs = new Set(); // "fi::label" composite keys
|
||
this._trackDrag = null; // active drag state object (rubber_band or kf_move)
|
||
this._trackDocMove = null; // document-level mousemove handler reference
|
||
this._trackDocUp = null; // document-level mouseup handler reference
|
||
this._lastKfMoveDelta = 0;
|
||
this._kfClipboard = null; // {anchorFi, entries:[{label,fi_offset,data}]}
|
||
|
||
// Viewport joint multi-select
|
||
this.selectedJoints = new Set(); // Set of label strings e.g. "body_4"
|
||
this._vpSelectRect = null; // {startX,startY,curX,curY} canvas rubber-band
|
||
|
||
// Graph extras
|
||
this.normalizeGraph = false;
|
||
|
||
// Graph editor interaction state
|
||
this._graphGrab = null; // {kfEntries, startX, startY, lastFiDelta, axisLock}
|
||
this._graphScale = null; // {kfEntries, pivotFi, pivotVal, startX, startY, lastFiDelta, lastValScale, axisLock}
|
||
this._graphPanDrag = null; // {startX, startY, origFStart, origFEnd, origVMin, origVMax}
|
||
this._lastGraphMouse = {x: 0, y: 0};
|
||
|
||
// Reference card (orbit view)
|
||
this.showReference = true;
|
||
this.referenceImg = null; // custom HTMLImageElement if loaded
|
||
this._refMeta = null; // { type, name, frameOffset, opacity }
|
||
this._refVideo = null; // HTMLVideoElement for video reference
|
||
this._refSeqFrames = null; // Array of HTMLImageElement for image sequence
|
||
|
||
// Hand IK/FK mode — per hand, when IK the entire hand follows the wrist rigidly
|
||
this.handIkMode = { rhand: false, lhand: false };
|
||
|
||
// Pre-rotation snapshot (reset each render cycle to prevent double-rotation)
|
||
this._preRotZ = null;
|
||
this._panelInputDragging = false;
|
||
this._showAll = false;
|
||
|
||
// Ref Frame — clean front-view frame 0 snapshot for the Retargeter
|
||
this.refFrameOverrides = {}; // {label: [x,y,conf,z]} — single-frame, no time key
|
||
this._inRefFrameMode = false;
|
||
this._refFrameBtn = null;
|
||
this._refFrameBanner = null;
|
||
|
||
// NLF / 3D depth toggle state
|
||
this._experimentalMode = false;
|
||
this._nlfData = null;
|
||
this.selectedNlfJoint = null; // {group:"nlf_body", index, label} — read-only selection for NLF graph view
|
||
this._dwposeAlpha = 1.0;
|
||
this._nlfAlpha = 0.5;
|
||
this._boneScale = 1.0;
|
||
this._nlfStatus = "idle"; // "idle" | "loading" | "ok" | "unavailable"
|
||
|
||
// Three.js 3D orbit renderer (lazy-init on first orbit render)
|
||
this._threeOrbit = null;
|
||
|
||
// Multi-panel layout
|
||
this._panelLayout = 1; // 1 | 2 | 4
|
||
this._panelViews = ["front", "orbit", "top", "side"];
|
||
this._activePanel = 0;
|
||
this._panelState = [
|
||
{ vpZoom:1, vpPanX:0, vpPanY:0, orbitYaw:-20, orbitPitch:15, orbitZoom:1.0 },
|
||
{ vpZoom:1, vpPanX:0, vpPanY:0, orbitYaw:-20, orbitPitch:15, orbitZoom:1.0 },
|
||
{ vpZoom:1, vpPanX:0, vpPanY:0, orbitYaw:-20, orbitPitch:15, orbitZoom:1.0 },
|
||
{ vpZoom:1, vpPanX:0, vpPanY:0, orbitYaw:-20, orbitPitch:15, orbitZoom:1.0 },
|
||
];
|
||
this._dragPanel = 0;
|
||
this._dragPanelRect = null;
|
||
this._camLocked = false; // Lock Camera to View — drag writes cam_pan/tilt KFs
|
||
this._camLockBtnRect = null; // Canvas-local rect for click detection
|
||
this._camLockPreview = null; // { pan?, tilt?, x?, y? } live preview during locked drag; committed on mouseUp
|
||
this._camOrtho = true; // Orthographic camera view (matches front view); false = perspective (Three.js)
|
||
this._camOrthoBtnRect = null;
|
||
this._camAimDrag = null; // { startLX, startLY, startCamX, startCamY, view, sx?, sy? } — drag camera aim point in views
|
||
this._orbitCenterOffset = null; // { x, y } — shifts ocx/ocy in _renderOrbitView for camera-view framing
|
||
|
||
// Camera system — state lives in this.overrides (cam_x/y/z/roll/tilt/pan/fov labels)
|
||
|
||
// Undo / Redo
|
||
this._undoStack = [];
|
||
this._redoStack = [];
|
||
this._dragPreState = null; // state captured at drag start; pushed on first actual change
|
||
|
||
// Graph per-coordinate selection (separate from dope-sheet selKfs)
|
||
this.graphSel = new Set(); // "fi::label::coord" e.g. "42::body_4::0"
|
||
this._selectedCamLabels = new Set(); // camera scalar labels selected for graph view
|
||
this._outputView = "front"; // "front" | "camera" — which view to bake into pose_data
|
||
|
||
// Canvas size override (0 = use input data dimensions)
|
||
this._canvasW = 0;
|
||
this._canvasH = 0;
|
||
this._canvasWInp = null; this._canvasHInp = null; // sidebar input refs
|
||
|
||
// Move gizmo (XY axis-constrained joint drag arrows in front/back views)
|
||
this._moveGizmoPos = null; // { cx, cy } in full canvas coords
|
||
this._moveGizmoDrag = null; // { axis:"x"|"y", group, index, label }
|
||
|
||
// Last clicked joint label in the layer panel (for shift-range select)
|
||
this._lastClickedLayerLabel = null;
|
||
|
||
// Auto Keyframe — when ON, dragging a joint automatically writes a keyframe
|
||
this.autoKeyframe = true;
|
||
this._tempKeys = new Set(); // "fi::label" keys written during a drag with autoKF ON
|
||
// (used to revert them on seekFrame when autoKF is toggled)
|
||
|
||
// Frame range (in/out points)
|
||
this.frameRangeStart = 0;
|
||
this.frameRangeEnd = 0; // set properly after data loads
|
||
|
||
// Layer locks — labels in this set cannot be dragged in the viewport
|
||
this.lockedLayers = new Set();
|
||
|
||
// Layer visibility — hidden layers are not drawn in the viewport (editing aid only)
|
||
this.hiddenGroups = new Set(); // group ids: "body", "rhand", "lhand", "face"
|
||
this.hiddenLayers = new Set(); // joint labels: "body_4", "rhand_0", etc.
|
||
|
||
// Per-joint detail panel expand state and live input refs
|
||
this.expandedJoints = new Set();
|
||
this._detailInputs = {}; // label → { xInp, yInp, confInp }
|
||
|
||
this._bgCache = {};
|
||
this._bgPending = new Set();
|
||
|
||
this._buildDOM();
|
||
this._fetchData();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// DOM
|
||
// -----------------------------------------------------------------------
|
||
_buildDOM() {
|
||
this.overlay = document.createElement("div");
|
||
Object.assign(this.overlay.style, {
|
||
position: "fixed", inset: "0", zIndex: "10000",
|
||
background: "rgba(0,0,0,0.96)",
|
||
display: "flex", flexDirection: "column",
|
||
fontFamily: "'Inter', -apple-system, system-ui, sans-serif", color: "#d4ddf4",
|
||
});
|
||
|
||
// Header
|
||
const header = document.createElement("div");
|
||
Object.assign(header.style, {
|
||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||
padding: "6px 16px", background: "#0d1020", borderBottom: "1px solid #2c3352",
|
||
flexShrink: "0",
|
||
});
|
||
header.innerHTML = `<span style="font-size:14px;font-weight:700;letter-spacing:0.04em;color:#d4ddf4;">Magos Pose Editor</span>`;
|
||
const _headerBtns = document.createElement("div");
|
||
_headerBtns.style.cssText = "display:flex;gap:6px;align-items:center;";
|
||
|
||
// ── File menu dropdown ──
|
||
const fileMenuWrap = document.createElement("div");
|
||
fileMenuWrap.style.cssText = "position:relative;";
|
||
const fileMenuBtn = this._mkBtn("☰ File", null, "#1a1a2e");
|
||
Object.assign(fileMenuBtn.style, { fontWeight: "bold", letterSpacing: "0.03em" });
|
||
|
||
const fileMenu = document.createElement("div");
|
||
Object.assign(fileMenu.style, {
|
||
display: "none", position: "absolute", top: "100%", left: "0",
|
||
background: "#0f1224", border: "1px solid #2c3352", borderRadius: "6px",
|
||
minWidth: "170px", zIndex: "9999", padding: "4px 0", marginTop: "3px",
|
||
boxShadow: "0 6px 24px rgba(0,0,0,0.6)",
|
||
});
|
||
|
||
const mkMenuItem = (label, icon, fn, color) => {
|
||
const item = document.createElement("button");
|
||
item.textContent = `${icon} ${label}`;
|
||
Object.assign(item.style, {
|
||
display: "block", width: "100%", textAlign: "left",
|
||
background: "none", border: "none", color: color || "#b0bcd4",
|
||
padding: "7px 14px", cursor: "pointer", fontSize: "12px",
|
||
});
|
||
item.addEventListener("mouseenter", () => { item.style.background = "#1e2540"; });
|
||
item.addEventListener("mouseleave", () => { item.style.background = "none"; });
|
||
item.addEventListener("click", () => { fileMenu.style.display = "none"; fn(); });
|
||
return item;
|
||
};
|
||
const mkMenuSep = () => {
|
||
const s = document.createElement("hr");
|
||
s.style.cssText = "border:none;border-top:1px solid #252a42;margin:3px 0;";
|
||
return s;
|
||
};
|
||
|
||
fileMenu.appendChild(mkMenuItem("New Scene…", "✦", () => this._showProjectDialog("new"), "#aaddff"));
|
||
fileMenu.appendChild(mkMenuItem("Edit Project…", "✎", () => this._showProjectDialog("edit"), "#cccccc"));
|
||
fileMenu.appendChild(mkMenuSep());
|
||
fileMenu.appendChild(mkMenuItem("Save Project", "💾", () => this._saveProject(), "#88bbff"));
|
||
fileMenu.appendChild(mkMenuItem("Load Project", "📂", () => this._projectFileInput.click(), "#88ffbb"));
|
||
|
||
fileMenuBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const open = fileMenu.style.display !== "none";
|
||
fileMenu.style.display = open ? "none" : "block";
|
||
});
|
||
document.addEventListener("click", () => { fileMenu.style.display = "none"; }, { capture: true, passive: true });
|
||
|
||
fileMenuWrap.appendChild(fileMenuBtn);
|
||
fileMenuWrap.appendChild(fileMenu);
|
||
_headerBtns.appendChild(fileMenuWrap);
|
||
_headerBtns.appendChild(this._mkBtn("Help [F1]", () => this._showHelp(), "#1a2a3a", "Show keyboard shortcuts and help (F1)"));
|
||
_headerBtns.appendChild(this._mkBtn("✕ Close", () => this.close(), "#444", "Close the editor without applying"));
|
||
header.appendChild(_headerBtns);
|
||
this.overlay.appendChild(header);
|
||
|
||
// Main (canvas + sidebar)
|
||
const main = document.createElement("div");
|
||
Object.assign(main.style, { flex: "1", display: "flex", overflow: "hidden", minHeight: "0" });
|
||
this.overlay.appendChild(main);
|
||
|
||
const vpWrap = document.createElement("div");
|
||
Object.assign(vpWrap.style, { flex: "1 1 0", position: "relative", background: "#08080f", overflow: "hidden" });
|
||
this.canvas = document.createElement("canvas");
|
||
Object.assign(this.canvas.style, { width: "100%", height: "100%", display: "block" });
|
||
vpWrap.appendChild(this.canvas);
|
||
|
||
this._showAllBtn = this._mkBtn("SHOW ALL", () => this._toggleShowAll(), "#1a1a2a", "Force-show all joints regardless of detection confidence");
|
||
Object.assign(this._showAllBtn.style, {
|
||
position:"absolute", top:"28px", right:"8px", zIndex:"10",
|
||
padding:"3px 9px", fontSize:"10px", fontWeight:"bold", letterSpacing:"0.5px",
|
||
color:"#4a5a78", border:"1px solid #2c3352", borderRadius:"3px",
|
||
});
|
||
vpWrap.appendChild(this._showAllBtn);
|
||
|
||
this._resetViewBtn = this._mkBtn("⟳ Reset View", () => this._resetView(), "#111828", "Reset zoom/pan/orbit on the active viewport");
|
||
Object.assign(this._resetViewBtn.style, {
|
||
position:"absolute", top:"28px", right:"88px", zIndex:"10",
|
||
padding:"3px 9px", fontSize:"10px",
|
||
color:"#4a5a78", border:"1px solid #2c3352", borderRadius:"3px",
|
||
});
|
||
vpWrap.appendChild(this._resetViewBtn);
|
||
|
||
main.appendChild(vpWrap);
|
||
|
||
// Sidebar — one element, explicit JS-pinned height so overflow-y:scroll always
|
||
// has a deterministic box to clip against. Flex stretch + min-height: 0 has
|
||
// proved unreliable across browsers; this approach simply doesn't fail.
|
||
const sidebar = document.createElement("div");
|
||
Object.assign(sidebar.style, {
|
||
flex: "0 0 214px", background: "#0e1220", borderLeft: "1px solid #2c3352",
|
||
padding: "10px 8px", display: "flex", flexDirection: "column", gap: "7px",
|
||
overflowY: "scroll", overflowX: "hidden",
|
||
boxSizing: "border-box",
|
||
scrollbarWidth: "thin", scrollbarColor: `${THEME.border} ${THEME.bgPanel}`,
|
||
});
|
||
main.appendChild(sidebar);
|
||
// Keep sidebar reference under both names so the resize handle keeps working
|
||
const sidebarOuter = sidebar;
|
||
|
||
const _pinSidebarHeight = () => {
|
||
const h = main.clientHeight;
|
||
if (h > 0) {
|
||
sidebar.style.height = `${h}px`;
|
||
sidebar.style.maxHeight = `${h}px`;
|
||
}
|
||
};
|
||
// Pin after the overlay attaches to body and gets a real layout
|
||
requestAnimationFrame(_pinSidebarHeight);
|
||
setTimeout(_pinSidebarHeight, 50);
|
||
if (typeof ResizeObserver !== "undefined") {
|
||
this._sidebarRO = new ResizeObserver(_pinSidebarHeight);
|
||
this._sidebarRO.observe(main);
|
||
}
|
||
window.addEventListener("resize", _pinSidebarHeight);
|
||
|
||
this.jointInfoEl = document.createElement("div");
|
||
Object.assign(this.jointInfoEl.style, {
|
||
background: "#141828", borderRadius: "6px", padding: "8px", fontSize: "12px",
|
||
});
|
||
this.jointInfoEl.textContent = "No joint selected";
|
||
sidebar.appendChild(this.jointInfoEl);
|
||
|
||
// Layout buttons [1][2][4] — controls how many panels are shown
|
||
const layoutRow = document.createElement("div");
|
||
layoutRow.style.cssText = "display:flex;gap:3px;";
|
||
this._layoutBtns = {};
|
||
for (const [n, icon] of [[1,"⊡ 1"],[2,"⊟ 2"],[4,"⊞ 4"]]) {
|
||
const b = this._mkBtn(icon, () => this._setLayout(n), "#1e3a5a");
|
||
Object.assign(b.style, { flex:"1", fontSize:"11px", padding:"3px 4px" });
|
||
this._layoutBtns[n] = b;
|
||
layoutRow.appendChild(b);
|
||
}
|
||
this._camBtns = {}; // kept empty for _updateCamBtns compat
|
||
sidebar.appendChild(layoutRow);
|
||
this._updateLayoutBtns();
|
||
|
||
// Ref Frame button — below layout, above Add
|
||
this._refFrameBtn = this._mkBtn("⊕ Ref Frame", () => this._toggleRefFrameMode(), "#1a2a1a",
|
||
"Enter Ref Frame mode to clean up frame 0's detection as a reference pose.\n" +
|
||
"Camera transforms are ignored — always front-view.\n" +
|
||
"Connect keyframe_data → Retargeter and set Reference Source = Ref Frame.");
|
||
this._refFrameBtn.style.width = "100%";
|
||
sidebar.appendChild(this._refFrameBtn);
|
||
|
||
// Add menu
|
||
const addMenuBtn = this._mkBtn("+ Add ▾", () => {
|
||
addMenuBody.style.display = addMenuBody.style.display === "none" ? "flex" : "none";
|
||
}, "#1a2a1a");
|
||
addMenuBtn.style.width = "100%";
|
||
sidebar.appendChild(addMenuBtn);
|
||
|
||
const addMenuBody = document.createElement("div");
|
||
addMenuBody.style.cssText = "display:none;flex-direction:column;gap:3px;padding-left:6px;";
|
||
|
||
this._addHandBtn = this._mkBtn("+ Hand", () => this._onAddHandClick(), "#1a3020", "Add a synthesised hand to the current frame");
|
||
this._addHandBtn.style.fontSize = "10px";
|
||
addMenuBody.appendChild(this._addHandBtn);
|
||
|
||
this._addHandChooser = document.createElement("div");
|
||
this._addHandChooser.style.cssText = "display:none;gap:4px;";
|
||
const _mkHandSideBtn = (label, side) => {
|
||
const b = this._mkBtn(label, () => { this._addHand(side); this._addHandChooser.style.display = "none"; }, "#1a2a3a");
|
||
b.style.flex = "1"; b.style.fontSize = "10px";
|
||
return b;
|
||
};
|
||
this._addHandChooser.appendChild(_mkHandSideBtn("+ Right", "rhand"));
|
||
this._addHandChooser.appendChild(_mkHandSideBtn("+ Left", "lhand"));
|
||
addMenuBody.appendChild(this._addHandChooser);
|
||
|
||
const addCamBtn = this._mkBtn("◎ Camera", () => { if (!this._hasCameraKeyframes()) this._addCamera(); }, "#0e2a12", "Add a scene camera with keyframable position / rotation / FOV");
|
||
addCamBtn.style.fontSize = "10px";
|
||
addCamBtn.style.color = "#88cc88";
|
||
addCamBtn.title = "Add a camera — adds ◎ Camera group to the dope sheet.\nKeyframe Position (X/Y/Z), Rotation (Roll/Tilt/Pan), and FOV to animate the camera.\nIn the Camera view panel, use Lock Camera to View to orbit-and-dolly and write KFs.";
|
||
this._addCamBtn = addCamBtn;
|
||
addMenuBody.appendChild(addCamBtn);
|
||
|
||
sidebar.appendChild(addMenuBody);
|
||
|
||
sidebar.appendChild(this._mkSidebarHeader("Quick Tips"));
|
||
const _tip = (txt) => {
|
||
const d = document.createElement("div");
|
||
d.style.cssText = `font-size:10px;color:${THEME.textDim};line-height:1.45;`;
|
||
d.textContent = txt;
|
||
return d;
|
||
};
|
||
sidebar.appendChild(_tip("Drag joints freely · Ctrl+drag = box-select"));
|
||
sidebar.appendChild(_tip("Shift+click joint = add to selection"));
|
||
sidebar.appendChild(_tip("K = Add Key · Del = Remove · H = Hide"));
|
||
sidebar.appendChild(_tip("Click a panel header to switch view"));
|
||
|
||
// ── Output Canvas Size ─────────────────────────────────────────────
|
||
sidebar.appendChild(this._mkSidebarHeader("Output Canvas"));
|
||
const canvasSizeRow = document.createElement("div");
|
||
canvasSizeRow.style.cssText = "display:flex;gap:4px;align-items:center;";
|
||
|
||
// Show effective output dim: explicit canvas value, or input-pose fallback.
|
||
// _canvasW=0 means "follow input" — typing a value makes it explicit.
|
||
const mkDimInp = (placeholder, getVal, getInputDim, setVal) => {
|
||
const inp = document.createElement("input");
|
||
Object.assign(inp, { type: "number", min: "0", max: "8192", step: "8", placeholder });
|
||
inp.value = getVal() || getInputDim() || "";
|
||
Object.assign(inp.style, {
|
||
width: "52px", background: "#0a0c18", color: "#d4ddf4",
|
||
border: "1px solid #2c3352", borderRadius: "3px", padding: "2px 4px", fontSize: "11px",
|
||
});
|
||
inp.title = `Output ${placeholder} in pixels (matches input by default — use presets or type to override)`;
|
||
inp.addEventListener("change", () => { setVal(parseInt(inp.value) || 0); });
|
||
return inp;
|
||
};
|
||
const wInp = mkDimInp("W", () => this._canvasW, () => this.poseW, v => { this._canvasW = v; });
|
||
const hInp = mkDimInp("H", () => this._canvasH, () => this.poseH, v => { this._canvasH = v; });
|
||
this._canvasWInp = wInp; this._canvasHInp = hInp;
|
||
const xLbl = document.createElement("span");
|
||
xLbl.textContent = "×"; xLbl.style.cssText = "color:#555;font-size:12px;";
|
||
canvasSizeRow.append(wInp, xLbl, hInp);
|
||
sidebar.appendChild(canvasSizeRow);
|
||
|
||
const presetRow = document.createElement("div");
|
||
presetRow.style.cssText = "display:flex;gap:2px;flex-wrap:wrap;";
|
||
// "Input" reverts to input-pose dims (clears explicit override).
|
||
const inputBtn = this._mkBtn("Input", () => {
|
||
this._canvasW = 0; this._canvasH = 0;
|
||
wInp.value = this.poseW; hInp.value = this.poseH;
|
||
}, "#1a2418");
|
||
inputBtn.title = "Revert to input data dimensions";
|
||
inputBtn.style.cssText += "font-size:9px;padding:2px 4px;flex:1;min-width:0;";
|
||
presetRow.appendChild(inputBtn);
|
||
for (const [w, h] of [[512,512],[768,768],[1024,1024],[768,1024],[1024,768],[1280,720]]) {
|
||
const lbl = w === h ? `${w}` : `${w}×${h}`;
|
||
const btn = this._mkBtn(lbl, () => {
|
||
this._canvasW = w; this._canvasH = h;
|
||
wInp.value = w; hInp.value = h;
|
||
}, "#131824");
|
||
btn.style.cssText += "font-size:9px;padding:2px 4px;flex:1;min-width:0;";
|
||
presetRow.appendChild(btn);
|
||
}
|
||
sidebar.appendChild(presetRow);
|
||
|
||
sidebar.appendChild(this._mkSidebarHeader("Reference Card"));
|
||
this.refToggleBtn = this._mkBtn("👁 Reference: ON", () => this._toggleReference(), "#1a2a1a", "Toggle the reference image/video underlay in the viewport");
|
||
this.refToggleBtn.style.cssText += "width:100%;font-size:11px;";
|
||
sidebar.appendChild(this.refToggleBtn);
|
||
|
||
const refBtnRow = document.createElement("div");
|
||
refBtnRow.style.cssText = "display:flex;gap:3px;margin-top:3px;";
|
||
const refImgBtn = this._mkBtn("🖼 Image", () => this._refFileInput.click(), "#2a2a1a", "Load a still image as the reference underlay");
|
||
Object.assign(refImgBtn.style, { flex: "1", fontSize: "10px" });
|
||
const refVidBtn = this._mkBtn("🎬 Video", () => this._refVideoInput.click(), "#1a2a2a", "Load a video file as the reference underlay (syncs to current frame)");
|
||
Object.assign(refVidBtn.style, { flex: "1", fontSize: "10px" });
|
||
const refSeqBtn = this._mkBtn("🎞 Seq", () => this._refSeqInput.click(), "#2a1a2a", "Load a sequence of images as a per-frame reference underlay");
|
||
Object.assign(refSeqBtn.style, { flex: "1", fontSize: "10px" });
|
||
refBtnRow.append(refImgBtn, refVidBtn, refSeqBtn);
|
||
sidebar.appendChild(refBtnRow);
|
||
|
||
const refOffRow = document.createElement("div");
|
||
refOffRow.style.cssText = "display:flex;align-items:center;gap:4px;margin-top:4px;";
|
||
const refOffLbl = document.createElement("span");
|
||
refOffLbl.textContent = "Offset:";
|
||
refOffLbl.style.cssText = "font-size:10px;color:#667;flex-shrink:0;";
|
||
this._refOffsetInp = document.createElement("input");
|
||
Object.assign(this._refOffsetInp, { type: "number", value: "0", min: "-9999", max: "9999" });
|
||
Object.assign(this._refOffsetInp.style, {
|
||
width: "50px", background: "#1e1e30", border: "1px solid #335",
|
||
color: "#aac", borderRadius: "3px", padding: "2px 4px", fontSize: "11px",
|
||
});
|
||
this._refOffsetInp.title = "Reference frame offset — drawn frame = current + offset";
|
||
this._refOffsetInp.addEventListener("change", () => {
|
||
const v = parseInt(this._refOffsetInp.value) || 0;
|
||
if (this._refMeta) this._refMeta.frameOffset = v;
|
||
this._renderFrame(this.currentFrame);
|
||
});
|
||
const refClearBtn = this._mkBtn("× Clear", () => {
|
||
this.referenceImg = null;
|
||
if (this._refVideo) { this._refVideo.src = ""; this._refVideo = null; }
|
||
this._refSeqFrames = null;
|
||
this._refMeta = null;
|
||
if (this._refRelinkBanner) this._refRelinkBanner.style.display = "none";
|
||
if (this._refInfoDiv) this._refInfoDiv.style.display = "none";
|
||
this._renderFrame(this.currentFrame);
|
||
}, "#2a1a1a");
|
||
Object.assign(refClearBtn.style, { fontSize: "10px", flex: "1" });
|
||
refOffRow.append(refOffLbl, this._refOffsetInp, refClearBtn);
|
||
sidebar.appendChild(refOffRow);
|
||
|
||
this._refRelinkBanner = document.createElement("div");
|
||
this._refRelinkBanner.style.cssText = "display:none;background:#2a1a00;border:1px solid #664;border-radius:4px;padding:4px 6px;margin-top:4px;font-size:10px;color:#cc8;";
|
||
sidebar.appendChild(this._refRelinkBanner);
|
||
|
||
this._refInfoDiv = document.createElement("div");
|
||
this._refInfoDiv.style.cssText = "display:none;font-size:9px;color:#556;margin-top:3px;line-height:1.5;padding:0 2px;";
|
||
sidebar.appendChild(this._refInfoDiv);
|
||
|
||
this._refFileInput = document.createElement("input");
|
||
Object.assign(this._refFileInput, { type: "file", accept: "image/*" });
|
||
this._refFileInput.style.display = "none";
|
||
this._refFileInput.addEventListener("change", e => {
|
||
const f = e.target.files[0]; if (!f) return;
|
||
const url = URL.createObjectURL(f);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
this.referenceImg = img;
|
||
if (this._refVideo) { this._refVideo.src = ""; this._refVideo = null; }
|
||
this._refSeqFrames = null;
|
||
this._refMeta = { type: "image", name: f.name, frameOffset: 0, opacity: 0.55 };
|
||
if (this._refOffsetInp) this._refOffsetInp.value = "0";
|
||
if (this._refRelinkBanner) this._refRelinkBanner.style.display = "none";
|
||
if (this._refInfoDiv) {
|
||
this._refInfoDiv.textContent = `${img.naturalWidth}×${img.naturalHeight} · static image`;
|
||
this._refInfoDiv.style.display = "block";
|
||
}
|
||
this._renderFrame(this.currentFrame);
|
||
};
|
||
img.src = url;
|
||
});
|
||
document.body.appendChild(this._refFileInput);
|
||
|
||
this._refVideoInput = document.createElement("input");
|
||
Object.assign(this._refVideoInput, { type: "file", accept: "video/*" });
|
||
this._refVideoInput.style.display = "none";
|
||
this._refVideoInput.addEventListener("change", e => {
|
||
const f = e.target.files[0]; if (!f) return;
|
||
this._loadRefVideo(f);
|
||
if (this._refRelinkBanner) this._refRelinkBanner.style.display = "none";
|
||
});
|
||
document.body.appendChild(this._refVideoInput);
|
||
|
||
this._refSeqInput = document.createElement("input");
|
||
Object.assign(this._refSeqInput, { type: "file", accept: "image/*", multiple: true });
|
||
this._refSeqInput.style.display = "none";
|
||
this._refSeqInput.addEventListener("change", e => {
|
||
const files = [...e.target.files]; if (!files.length) return;
|
||
this._loadRefSequence(files);
|
||
if (this._refRelinkBanner) this._refRelinkBanner.style.display = "none";
|
||
});
|
||
document.body.appendChild(this._refSeqInput);
|
||
|
||
// ---- 3D Depth / NLF Section ----
|
||
sidebar.appendChild(this._mkSidebarHeader("3D Depth (Experimental)"));
|
||
|
||
this._expBtn = this._mkBtn("⬡ Turn to 3D: OFF", () => this._toggleExperimental(), "#1a1a2a", "Enable experimental 3D mode (auto-quad layout + NLF depth + 3D bone radius)");
|
||
this._expBtn.style.width = "100%";
|
||
this._expBtn.style.fontSize = "11px";
|
||
sidebar.appendChild(this._expBtn);
|
||
|
||
const expNote = document.createElement("div");
|
||
expNote.textContent = "⚗ Experimental — requires NLF model on the Extractor";
|
||
expNote.style.cssText = "font-size:9px;color:#c9a15a;font-style:italic;padding:2px 4px 4px;line-height:1.3;";
|
||
sidebar.appendChild(expNote);
|
||
|
||
this._nlfPanel = document.createElement("div");
|
||
this._nlfPanel.style.cssText = "display:none;padding:4px 0;";
|
||
|
||
this._nlfStatusEl = document.createElement("div");
|
||
this._nlfStatusEl.style.cssText = "font-size:9px;color:#667;margin-bottom:4px;line-height:1.4;";
|
||
this._nlfStatusEl.textContent = "NLF: not loaded";
|
||
this._nlfPanel.appendChild(this._nlfStatusEl);
|
||
|
||
// Slider moves fire at mousemove rate — coalesce redraws into one frame
|
||
// so we don't dispose+rebuild every Three.js mesh per input event.
|
||
const scheduleRender = () => {
|
||
if (this._sliderRaf) return;
|
||
this._sliderRaf = requestAnimationFrame(() => {
|
||
this._sliderRaf = 0;
|
||
this._renderFrame(this.currentFrame);
|
||
});
|
||
};
|
||
|
||
// Slider + eye-toggle helper. Eye toggles between 0% and last non-zero value.
|
||
const _mkSliderWithEye = (labelText, defaultValue, getProp, setProp) => {
|
||
// Header row: label + eye button
|
||
const headerRow = document.createElement("div");
|
||
headerRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;";
|
||
const lbl = this._mkLabel(labelText);
|
||
lbl.style.margin = "6px 0 2px";
|
||
const eyeBtn = document.createElement("button");
|
||
Object.assign(eyeBtn.style, {
|
||
background: "none", border: "none", cursor: "pointer",
|
||
fontSize: "12px", padding: "0 2px", flexShrink: "0",
|
||
lineHeight: "1", color: THEME.textDim,
|
||
});
|
||
eyeBtn.title = "Toggle visibility (click) — last value is restored when turning back on";
|
||
headerRow.append(lbl, eyeBtn);
|
||
this._nlfPanel.appendChild(headerRow);
|
||
// Slider
|
||
const slider = document.createElement("input");
|
||
Object.assign(slider, { type: "range", min: "0", max: "1", step: "0.05", value: String(defaultValue) });
|
||
slider.style.width = "100%";
|
||
this._nlfPanel.appendChild(slider);
|
||
// Update eye icon based on whether value is > 0
|
||
const lastNonZero = { v: defaultValue > 0 ? defaultValue : 1 };
|
||
const refreshEye = () => {
|
||
const v = getProp();
|
||
eyeBtn.textContent = v > 0 ? "👁" : "🙈";
|
||
eyeBtn.style.opacity = v > 0 ? "1" : "0.5";
|
||
};
|
||
slider.addEventListener("input", () => {
|
||
const v = parseFloat(slider.value);
|
||
setProp(v);
|
||
if (v > 0) lastNonZero.v = v;
|
||
refreshEye();
|
||
scheduleRender();
|
||
});
|
||
eyeBtn.addEventListener("click", () => {
|
||
const cur = getProp();
|
||
const next = cur > 0 ? 0 : lastNonZero.v;
|
||
setProp(next);
|
||
slider.value = String(next);
|
||
refreshEye();
|
||
scheduleRender();
|
||
});
|
||
refreshEye();
|
||
return slider;
|
||
};
|
||
|
||
this._dwposeAlphaSlider = _mkSliderWithEye("DWPose Opacity", 1,
|
||
() => this._dwposeAlpha ?? 1,
|
||
v => { this._dwposeAlpha = v; });
|
||
|
||
this._nlfAlphaSlider = _mkSliderWithEye("NLF Overlay Opacity", 0.5,
|
||
() => this._nlfAlpha ?? 0.5,
|
||
v => { this._nlfAlpha = v; });
|
||
|
||
this._nlfPanel.appendChild(this._mkLabel("3D Bone Radius"));
|
||
this._boneScaleSlider = document.createElement("input");
|
||
Object.assign(this._boneScaleSlider, { type: "range", min: "0.1", max: "3", step: "0.05", value: "1" });
|
||
this._boneScaleSlider.style.width = "100%";
|
||
this._boneScaleSlider.addEventListener("input", () => {
|
||
this._boneScale = parseFloat(this._boneScaleSlider.value);
|
||
scheduleRender();
|
||
});
|
||
this._nlfPanel.appendChild(this._boneScaleSlider);
|
||
|
||
this._nlfApplyBtn = this._mkBtn("⬇ Bake Z Depth", () => this._applyNlfToDwpose(), "#1a2a1a", "Bake NLF-estimated Z depth into all DWPose keyframes");
|
||
this._nlfApplyBtn.style.cssText += "width:100%;font-size:10px;margin-top:4px;";
|
||
this._nlfApplyBtn.title = "Write NLF Z-depth into all body joints for every frame.\nDWPose XY positions are unchanged — only depth (Z) is set from NLF 3D data.";
|
||
this._nlfPanel.appendChild(this._nlfApplyBtn);
|
||
|
||
this._nlfUnbakeBtn = this._mkBtn("⬆ Unbake Z Depth", () => this._unbakeZDepth(), "#2a1a1a", "Clear baked Z depth from all keyframes (revert to flat 2D)");
|
||
this._nlfUnbakeBtn.style.cssText += "width:100%;font-size:10px;margin-top:2px;";
|
||
this._nlfUnbakeBtn.title = "Clear all baked Z-depth from body joints, flattening back to 2D. XY edits are preserved.";
|
||
this._nlfPanel.appendChild(this._nlfUnbakeBtn);
|
||
|
||
this._nlfDopeBtn = this._mkBtn("📊 Edit NLF Data", () => this._switchDataMode("nlf"), "#1a1a2a",
|
||
"Switch the timeline to NLF mode — Dope Sheet + Graph Editor + viewport drags edit NLF keyframes (parallel to DWPose).");
|
||
this._nlfDopeBtn.style.cssText += "width:100%;font-size:10px;margin-top:6px;";
|
||
this._nlfPanel.appendChild(this._nlfDopeBtn);
|
||
|
||
sidebar.appendChild(this._nlfPanel);
|
||
|
||
// (Graph editor controls are now inside the graph panel itself — see graph panel setup)
|
||
|
||
// ---- Resize handle: viewport/sidebar splitter ----
|
||
const sidebarHandle = document.createElement("div");
|
||
Object.assign(sidebarHandle.style, {
|
||
width: "5px", flexShrink: "0", cursor: "ew-resize",
|
||
background: "#161c34", borderLeft: "1px solid #2c3352",
|
||
transition: "background 0.15s",
|
||
});
|
||
sidebarHandle.title = "Drag to resize sidebar";
|
||
sidebarHandle.addEventListener("mouseenter", () => { sidebarHandle.style.background = "#253050"; });
|
||
sidebarHandle.addEventListener("mouseleave", () => { sidebarHandle.style.background = "#161c34"; });
|
||
sidebarHandle.addEventListener("mousedown", ev => {
|
||
ev.preventDefault();
|
||
const startX = ev.clientX, startW = sidebarOuter.getBoundingClientRect().width;
|
||
const onMove = mv => { sidebarOuter.style.flex = `0 0 ${Math.max(140, Math.min(420, startW - (mv.clientX - startX)))}px`; };
|
||
const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
});
|
||
// Insert handle between vpWrap and sidebar
|
||
main.insertBefore(sidebarHandle, sidebarOuter);
|
||
|
||
// ---- Timeline panel ----
|
||
const timeline = document.createElement("div");
|
||
this._timelineEl = timeline;
|
||
Object.assign(timeline.style, {
|
||
flexShrink: "0", background: "#0d1020", borderTop: "1px solid #2c3352",
|
||
display: "flex", flexDirection: "column", height: "260px",
|
||
});
|
||
// Resize handle at top of timeline
|
||
const tlHandle = document.createElement("div");
|
||
Object.assign(tlHandle.style, {
|
||
height: "5px", flexShrink: "0", cursor: "ns-resize",
|
||
background: "#181e34", borderTop: "1px solid #2c3352",
|
||
});
|
||
tlHandle.title = "Drag to resize timeline";
|
||
tlHandle.addEventListener("mouseenter", () => { tlHandle.style.background = "#253050"; });
|
||
tlHandle.addEventListener("mouseleave", () => { tlHandle.style.background = "#181e34"; });
|
||
tlHandle.addEventListener("mousedown", ev => {
|
||
ev.preventDefault();
|
||
const startY = ev.clientY, startH = timeline.getBoundingClientRect().height;
|
||
const onMove = mv => {
|
||
const newH = Math.max(80, Math.min(600, startH - (mv.clientY - startY)));
|
||
timeline.style.height = `${newH}px`;
|
||
};
|
||
const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
});
|
||
this.overlay.appendChild(tlHandle);
|
||
this.overlay.appendChild(timeline);
|
||
|
||
// Tab bar
|
||
const tabBar = document.createElement("div");
|
||
Object.assign(tabBar.style, {
|
||
display: "flex", alignItems: "center", flexShrink: "0",
|
||
borderBottom: "1px solid #222a42", background: "#0a0c18",
|
||
});
|
||
this.tabBtns = {};
|
||
for (const [id, label] of [["dope", "Dope Sheet"], ["graph", "Graph Editor"]]) {
|
||
const b = document.createElement("button");
|
||
b.textContent = label;
|
||
Object.assign(b.style, {
|
||
background: "none", border: "none", borderBottom: "2px solid transparent",
|
||
color: "#888", padding: "5px 14px", cursor: "pointer", fontSize: "12px",
|
||
});
|
||
b.addEventListener("click", () => this._switchTab(id));
|
||
this.tabBtns[id] = b;
|
||
tabBar.appendChild(b);
|
||
}
|
||
|
||
// DWpose / NLF data-mode toggle — switches the content of both Dope Sheet and Graph Editor
|
||
const modeWrap = document.createElement("div");
|
||
modeWrap.style.cssText = `display:flex;align-items:center;margin-left:auto;gap:0;padding:0 8px;`;
|
||
this.dataMode = "dwpose";
|
||
this.dataModeBtns = {};
|
||
for (const [id, label, title] of [
|
||
["dwpose", "DWpose", "Show editable DWPose keyframes"],
|
||
["nlf", "NLF", "Show NLF detection data (read-only)"],
|
||
]) {
|
||
const b = document.createElement("button");
|
||
b.textContent = label;
|
||
b.title = title;
|
||
Object.assign(b.style, {
|
||
background: THEME.bgPanel, border: `1px solid ${THEME.border}`,
|
||
color: THEME.textDim, padding: "3px 12px", cursor: "pointer",
|
||
fontSize: "11px", fontWeight: "600",
|
||
borderRadius: id === "dwpose" ? "4px 0 0 4px" : "0 4px 4px 0",
|
||
borderRight: id === "dwpose" ? "none" : `1px solid ${THEME.border}`,
|
||
transition: "background 90ms ease, color 90ms ease",
|
||
});
|
||
b.addEventListener("click", () => this._switchDataMode(id));
|
||
this.dataModeBtns[id] = b;
|
||
modeWrap.appendChild(b);
|
||
}
|
||
tabBar.appendChild(modeWrap);
|
||
timeline.appendChild(tabBar);
|
||
|
||
// Transport controls (always visible)
|
||
const ctrlRow = document.createElement("div");
|
||
Object.assign(ctrlRow.style, {
|
||
display: "flex", alignItems: "center", gap: "4px",
|
||
padding: "4px 8px", flexShrink: "0", borderBottom: "1px solid #1e2438",
|
||
flexWrap: "wrap",
|
||
});
|
||
const toStartBtn = this._mkTransportBtn("⏮", "Jump to first frame", () => { this._stopPlayback(); this._seekFrame(0); });
|
||
const stepBwdBtn = this._mkTransportBtn("◀◀","Step back one frame", () => { this._stopPlayback(); this._seekFrame(this.currentFrame - 1); });
|
||
const playBwdBtn = this._mkTransportBtn("◀", "Play Backwards", () => this._togglePlay("backward"));
|
||
const playFwdBtn = this._mkTransportBtn("▶", "Play Forward", () => this._togglePlay("forward"));
|
||
const stepFwdBtn = this._mkTransportBtn("▶▶","Step forward one frame", () => { this._stopPlayback(); this._seekFrame(this.currentFrame + 1); });
|
||
const toEndBtn = this._mkTransportBtn("⏭", "Jump to last frame", () => { this._stopPlayback(); this._seekFrame(this.frameCount - 1); });
|
||
this.playFwdBtn = playFwdBtn; this.playBwdBtn = playBwdBtn;
|
||
|
||
// Loop mode toggle: Cycle → Ping-Pong → Off → Cycle …
|
||
const loopBtn = document.createElement("button");
|
||
const LOOP_STATES = ["cycle", "pingpong", "off"];
|
||
const LOOP_LABEL = { cycle: "↺", pingpong: "⇄", off: "→|" };
|
||
const LOOP_TITLE = { cycle: "Loop: Cycle (click to switch)", pingpong: "Loop: Ping-Pong (click to switch)", off: "Loop: Off (click to switch)" };
|
||
const LOOP_COLOR = { cycle: "#7affaa", pingpong: "#88ccff", off: "#888" };
|
||
const LOOP_BG = { cycle: "#1a4a2a", pingpong: "#1a2a4a", off: "#1e1e30" };
|
||
const LOOP_BORDER = { cycle: "#3aaa6a", pingpong: "#3a6aaa", off: "#3a3a4a" };
|
||
const applyLoopBtnStyle = () => {
|
||
const m = this.loopMode;
|
||
loopBtn.textContent = LOOP_LABEL[m];
|
||
loopBtn.title = LOOP_TITLE[m];
|
||
Object.assign(loopBtn.style, {
|
||
background: LOOP_BG[m], borderColor: LOOP_BORDER[m], color: LOOP_COLOR[m],
|
||
});
|
||
};
|
||
Object.assign(loopBtn.style, {
|
||
border: "1px solid", borderRadius: "3px", padding: "2px 6px",
|
||
cursor: "pointer", fontSize: "13px", fontFamily: "monospace", minWidth: "28px",
|
||
});
|
||
loopBtn.addEventListener("click", () => {
|
||
const idx = LOOP_STATES.indexOf(this.loopMode);
|
||
this.loopMode = LOOP_STATES[(idx + 1) % LOOP_STATES.length];
|
||
this._playDir = this.playState !== "stopped" ? this.playState : null;
|
||
applyLoopBtnStyle();
|
||
});
|
||
applyLoopBtnStyle();
|
||
this.loopBtn = loopBtn;
|
||
|
||
const fpsLabel = this._mkLabel("FPS:");
|
||
const fpsInput = document.createElement("input");
|
||
Object.assign(fpsInput, { type: "number", min: "1", max: "120", value: "24" });
|
||
Object.assign(fpsInput.style, {
|
||
width: "38px", background: "#222", color: "#fff",
|
||
border: "1px solid #444", borderRadius: "3px", padding: "1px 3px", fontSize: "11px",
|
||
});
|
||
fpsInput.addEventListener("change", () => {
|
||
this.playFPS = Math.max(1, Math.min(120, parseInt(fpsInput.value) || 24));
|
||
fpsInput.value = this.playFPS;
|
||
});
|
||
this._fpsInput = fpsInput;
|
||
|
||
this.frameLabel = document.createElement("span");
|
||
this.frameLabel.style.cssText = "font-size:12px;min-width:90px;";
|
||
this.frameLabel.textContent = "Frame: 0 / 0";
|
||
|
||
this.scrubber = document.createElement("input");
|
||
Object.assign(this.scrubber, { type: "range", min: "0", max: "0", step: "1", value: "0" });
|
||
this.scrubber.style.cssText = "flex:1;min-width:80px;";
|
||
this.scrubber.addEventListener("mousedown", () => this._stopPlayback());
|
||
this.scrubber.addEventListener("input", () => this._seekFrame(parseInt(this.scrubber.value)));
|
||
|
||
// Frame range in/out inputs
|
||
const mkRangeInput = (placeholder, getVal, setVal) => {
|
||
const inp = document.createElement("input");
|
||
Object.assign(inp, { type: "number", min: "0", placeholder });
|
||
Object.assign(inp.style, {
|
||
width: "42px", background: "#222", color: "#adf",
|
||
border: "1px solid #335", borderRadius: "3px", padding: "1px 3px", fontSize: "11px",
|
||
});
|
||
inp.title = placeholder === "In" ? "Frame Range Start (In point)" : "Frame Range End (Out point)";
|
||
inp.addEventListener("change", () => {
|
||
const v = parseInt(inp.value);
|
||
if (!isNaN(v)) { setVal(Math.max(0, Math.min(v, this.frameCount - 1))); }
|
||
inp.value = getVal();
|
||
this._renderTrack(); this._renderFrame(this.currentFrame);
|
||
});
|
||
return inp;
|
||
};
|
||
this.rangeStartInp = mkRangeInput("In",
|
||
() => this.frameRangeStart,
|
||
v => { this.frameRangeStart = Math.min(v, this.frameRangeEnd); });
|
||
this.rangeEndInp = mkRangeInput("Out",
|
||
() => this.frameRangeEnd,
|
||
v => { this.frameRangeEnd = Math.max(v, this.frameRangeStart); });
|
||
const rangeLabel = this._mkLabel("Range:");
|
||
|
||
ctrlRow.append(toStartBtn, stepBwdBtn, playBwdBtn, this.frameLabel, fpsLabel, fpsInput,
|
||
playFwdBtn, stepFwdBtn, toEndBtn, loopBtn, rangeLabel, this.rangeStartInp, this.rangeEndInp,
|
||
this.scrubber);
|
||
timeline.appendChild(ctrlRow);
|
||
|
||
// ---- Dope Sheet panel ----
|
||
this.dopePanel = document.createElement("div");
|
||
Object.assign(this.dopePanel.style, { display: "flex", flexDirection: "column", flex: "1", overflow: "hidden", minHeight: "0" });
|
||
|
||
const layerArea = document.createElement("div");
|
||
Object.assign(layerArea.style, { display: "flex", flex: "1", overflow: "hidden", minHeight: "0" });
|
||
this.dopePanel.appendChild(layerArea);
|
||
|
||
this.layerPanel = document.createElement("div");
|
||
Object.assign(this.layerPanel.style, {
|
||
width: "200px", flexShrink: "0", overflowY: "auto", overflowX: "hidden",
|
||
background: "#0d0d1a", borderRight: "1px solid #2a2a3a",
|
||
});
|
||
layerArea.appendChild(this.layerPanel);
|
||
|
||
// Resize handle between layer panel and track canvas
|
||
const layerHandle = document.createElement("div");
|
||
Object.assign(layerHandle.style, {
|
||
width: "5px", flexShrink: "0", cursor: "ew-resize",
|
||
background: "#1e1e30", borderLeft: "1px solid #2a2a3a",
|
||
transition: "background 0.15s",
|
||
});
|
||
layerHandle.title = "Drag to resize layer panel";
|
||
layerHandle.addEventListener("mouseenter", () => { layerHandle.style.background = "#2a3a5a"; });
|
||
layerHandle.addEventListener("mouseleave", () => { layerHandle.style.background = "#1e1e30"; });
|
||
layerHandle.addEventListener("mousedown", ev => {
|
||
ev.preventDefault();
|
||
const startX = ev.clientX;
|
||
const startW = this.layerPanel.getBoundingClientRect().width;
|
||
const onMove = mv => {
|
||
const newW = Math.max(120, Math.min(500, startW + (mv.clientX - startX)));
|
||
this.layerPanel.style.width = `${newW}px`;
|
||
this._buildLayerPanel();
|
||
};
|
||
const onUp = () => {
|
||
document.removeEventListener("mousemove", onMove);
|
||
document.removeEventListener("mouseup", onUp);
|
||
};
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
});
|
||
layerArea.appendChild(layerHandle);
|
||
|
||
this.trackWrapper = document.createElement("div");
|
||
Object.assign(this.trackWrapper.style, { flex: "1", overflow: "auto", position: "relative" });
|
||
this.trackCanvas = document.createElement("canvas");
|
||
this.trackCanvas.style.cssText = "display:block;";
|
||
this.trackWrapper.appendChild(this.trackCanvas);
|
||
layerArea.appendChild(this.trackWrapper);
|
||
|
||
// Graph canvas — shares the same flex area as trackWrapper, toggled by _switchTab
|
||
this.graphCanvas = document.createElement("canvas");
|
||
Object.assign(this.graphCanvas.style, { flex: "1", alignSelf: "stretch", display: "none", minWidth: "0", minHeight: "0" });
|
||
layerArea.appendChild(this.graphCanvas);
|
||
|
||
this.layerPanel.addEventListener("scroll", () => { this.trackWrapper.scrollTop = this.layerPanel.scrollTop; });
|
||
this.trackWrapper.addEventListener("scroll", () => { this.layerPanel.scrollTop = this.trackWrapper.scrollTop; });
|
||
this.trackCanvas.addEventListener("mousedown", e => this._onTrackMouseDown(e));
|
||
this.trackCanvas.addEventListener("contextmenu", e => { e.preventDefault(); this._onTrackRightClick(e); });
|
||
|
||
const kfBar = document.createElement("div");
|
||
Object.assign(kfBar.style, {
|
||
display: "flex", gap: "4px", padding: "3px 8px",
|
||
background: "#0a0c18", borderTop: "1px solid #1e2438",
|
||
flexShrink: "0", alignItems: "center", flexWrap: "wrap",
|
||
});
|
||
const insBtn = this._mkBtn("⬦ Add Key [K]", () => this._insertKeyframeSelected(), "#1a3a2a");
|
||
const delBtn = this._mkBtn("✕ Del Key [Del]", () => this._deleteKeyframeSelected(), "#3a1a1a");
|
||
const trimBefBtn = this._mkBtn("◀K✕ Before", () => this._trimKeyframesBefore(), "#2a1a2a");
|
||
const trimAftBtn = this._mkBtn("✕K▶ After", () => this._trimKeyframesAfter(), "#2a1a2a");
|
||
trimBefBtn.title = "Delete all keyframes BEFORE the current cursor position";
|
||
trimAftBtn.title = "Delete all keyframes AFTER the current cursor position";
|
||
// Auto Keyframe toggle
|
||
this.autoKfBtn = this._mkBtn("⬤ Auto Key", () => this._toggleAutoKeyframe(), "#3a2a1a", "Auto-create a keyframe whenever you drag a joint");
|
||
this.autoKfBtn.title = "Auto Keyframe: automatically insert a keyframe when you move a joint.\nWhen OFF, drag changes are temporary until you press K.";
|
||
this._updateAutoKfBtn();
|
||
for (const b of [insBtn, delBtn, trimBefBtn, trimAftBtn, this.autoKfBtn]) {
|
||
b.style.fontSize = "11px"; b.style.padding = "2px 8px";
|
||
}
|
||
|
||
// Interpolation mode buttons — half size, on the right end of kfBar
|
||
const interpSpacer = document.createElement("span");
|
||
interpSpacer.style.cssText = "flex:1;";
|
||
const interpKfLabel = document.createElement("span");
|
||
interpKfLabel.style.cssText = "font-size:9px;color:#4a5a78;white-space:nowrap;";
|
||
interpKfLabel.textContent = "KF:";
|
||
this.interpLabel = interpKfLabel;
|
||
this.interpBtns = {};
|
||
for (const { val, title, svg } of INTERP_MODES) {
|
||
const btn = document.createElement("button");
|
||
btn.title = title; btn.innerHTML = svg;
|
||
Object.assign(btn.style, {
|
||
background: "#1d2138", border: "1px solid #2c3352", color: "#7888a8",
|
||
borderRadius: "3px", cursor: "pointer", padding: "0",
|
||
width: "20px", height: "16px",
|
||
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
||
flexShrink: "0", overflow: "hidden",
|
||
});
|
||
const svgEl = btn.querySelector("svg");
|
||
if (svgEl) { svgEl.style.width = "100%"; svgEl.style.height = "100%"; }
|
||
btn.addEventListener("click", () => {
|
||
const hasDopeSel = this.selKfs.size > 0;
|
||
const hasGraphSel = this.graphSel.size > 0;
|
||
if (!hasDopeSel && !hasGraphSel) return;
|
||
this._pushUndo();
|
||
if (hasDopeSel) {
|
||
for (const key of this.selKfs) {
|
||
const [fStr, label] = key.split("::");
|
||
const fi = parseInt(fStr);
|
||
if (!this.tweens[fi]) this.tweens[fi] = {};
|
||
this.tweens[fi][label] = val;
|
||
}
|
||
} else {
|
||
const seen = new Set();
|
||
for (const key of this.graphSel) {
|
||
const parts = key.split("::");
|
||
const id = `${parts[0]}::${parts[1]}`;
|
||
if (seen.has(id)) continue;
|
||
seen.add(id);
|
||
const fi = parseInt(parts[0]), label = parts[1];
|
||
if (!this.tweens[fi]) this.tweens[fi] = {};
|
||
this.tweens[fi][label] = val;
|
||
}
|
||
}
|
||
this._updateInterpBtns();
|
||
this._renderFrame(this.currentFrame);
|
||
this._renderTrack();
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
});
|
||
this.interpBtns[val] = btn;
|
||
}
|
||
kfBar.append(insBtn, delBtn, trimBefBtn, trimAftBtn, this.autoKfBtn,
|
||
interpSpacer, interpKfLabel, ...Object.values(this.interpBtns));
|
||
this.kfBar = kfBar;
|
||
// Dope-only buttons — hidden in graph mode (graphRightPanel has equivalents).
|
||
// Interp buttons + spacer + label stay visible in both modes since they
|
||
// operate on graph-editor selection too.
|
||
this._kfBarDopeOnly = [insBtn, delBtn, trimBefBtn, trimAftBtn, this.autoKfBtn];
|
||
this.dopePanel.appendChild(kfBar);
|
||
|
||
timeline.appendChild(this.dopePanel);
|
||
// The DWpose dopePanel doubles as the NLF dopesheet — _getLayerDefs swaps rows by dataMode.
|
||
this.nlfPanel = this.dopePanel;
|
||
|
||
// ── Graph controls panel (right side, shown only in graph mode) ──
|
||
this.graphRightPanel = document.createElement("div");
|
||
Object.assign(this.graphRightPanel.style, {
|
||
width: "98px", flexShrink: "0", background: "#0b0b18",
|
||
borderLeft: "1px solid #2a2a3a", overflowY: "auto",
|
||
display: "none", flexDirection: "column", gap: "3px", padding: "5px 4px",
|
||
});
|
||
const mkGraphBtn = (text, fn, bg, title) => {
|
||
const b = this._mkBtn(text, fn, bg);
|
||
b.style.cssText += "font-size:10px;padding:2px 4px;width:100%;text-align:left;";
|
||
if (title) b.title = title;
|
||
return b;
|
||
};
|
||
const mkGDiv = () => { const d=document.createElement("hr"); d.style.cssText="border:none;border-top:1px solid #1e1e2e;margin:2px 0;"; return d; };
|
||
|
||
// Keyframe section
|
||
this.graphRightPanel.appendChild(mkGraphBtn("⬦ Add Key [K]", () => this._insertKeyframeSelected(), "#1a3a2a", "Insert keyframe at current frame for active channels"));
|
||
this.graphRightPanel.appendChild(mkGraphBtn("✕ Del Key [X]", () => this._deleteGraphSelected(), "#3a1a1a", "Delete selected keyframes (X or Delete)"));
|
||
this.graphAutoKfBtn = mkGraphBtn("⬤ Auto Key", () => this._toggleAutoKeyframe(), "#3a2a1a", this.autoKfBtn?.title ?? "");
|
||
this.graphRightPanel.appendChild(this.graphAutoKfBtn);
|
||
this.graphRightPanel.appendChild(mkGDiv());
|
||
|
||
// Edit section
|
||
this.graphRightPanel.appendChild(mkGraphBtn("↔ Scale [S]", () => this._startGraphScale(), "#1a2a3a", "Scale selected keyframes around selection center\nS=both axes, then X=time only, Y=value only"));
|
||
this.graphRightPanel.appendChild(mkGraphBtn("〜 Smooth [O]", () => this._smoothSelectedKfs(), "#1a2a2a", "Gaussian smooth selected keyframe values\n(only selected KFs are modified)"));
|
||
this.graphRightPanel.appendChild(mkGDiv());
|
||
|
||
// View section
|
||
this.graphRightPanel.appendChild(mkGraphBtn("⊡ Fit View [.]", () => { this._graphFitView(); this._renderGraphEditor(); }, "#1e2030", "Frame all keyframes in view (or press . / Home)"));
|
||
this.graphRightPanel.appendChild(mkGraphBtn("⊞ Auto Range", () => { this._graphAutoFitRange(); this._renderGraphEditor(); }, "#1e2030", "Fit Y axis to actual data range, outlier-clamped ±100px"));
|
||
this.normalizeBtn = mkGraphBtn("⇥ Normalize", () => {
|
||
this.normalizeGraph = !this.normalizeGraph;
|
||
this.normalizeBtn.style.background = this.normalizeGraph ? "#1a3a5a" : "#1e2030";
|
||
this.normalizeBtn.style.borderColor = this.normalizeGraph ? "#3a88cc" : "#444";
|
||
this._renderGraphEditor();
|
||
}, "#1e2030", "Normalize all curves to −1…1 for comparison");
|
||
this.graphRightPanel.appendChild(this.normalizeBtn);
|
||
this.graphRightPanel.appendChild(mkGDiv());
|
||
|
||
// Curve visibility: X and Y toggles
|
||
const xyRow = document.createElement("div");
|
||
xyRow.style.cssText = "display:flex;gap:3px;";
|
||
const mkXYBtn = (label, col, prop) => {
|
||
const b = this._mkBtn(label, () => { this[prop]=!this[prop]; b.style.opacity=this[prop]?"1":"0.3"; this._renderGraphEditor(); }, col);
|
||
Object.assign(b.style, { fontSize:"11px", padding:"1px 6px", flex:"1" });
|
||
return b;
|
||
};
|
||
xyRow.append(mkXYBtn("X","#883333","graphShowX"), mkXYBtn("Y","#338866","graphShowY"));
|
||
this.graphRightPanel.appendChild(xyRow);
|
||
this.graphRightPanel.appendChild(mkGDiv());
|
||
|
||
layerArea.appendChild(this.graphRightPanel);
|
||
|
||
// Action row
|
||
const actRow = document.createElement("div");
|
||
Object.assign(actRow.style, {
|
||
display: "flex", justifyContent: "flex-end", gap: "8px",
|
||
padding: "6px 10px", flexShrink: "0", borderTop: "1px solid #1e2438",
|
||
});
|
||
const applyBtn = this._mkBtn("Apply", () => this._applyChanges(), "#1a6a3a", "Save all edits and push them through to the workflow");
|
||
applyBtn.style.fontWeight = "bold";
|
||
const undoBtn = this._mkBtn("↩", () => this._undo(), "#2a2a3a", "Undo (Ctrl+Z)");
|
||
const redoBtn = this._mkBtn("↪", () => this._redo(), "#2a2a3a", "Redo (Ctrl+Y)");
|
||
Object.assign(undoBtn.style, { padding: "0 7px", minWidth: "28px" });
|
||
Object.assign(redoBtn.style, { padding: "0 7px", minWidth: "28px" });
|
||
const resetCacheBtn = this._mkBtn("⟳ Reset", () => this._resetCache(), "#3a1a1a",
|
||
"Clear server-side detection cache. Run the workflow again to re-detect from scratch.\nYour overrides and keyframes are kept.");
|
||
|
||
actRow.append(undoBtn, redoBtn, applyBtn, resetCacheBtn, this._mkBtn("Close", () => this.close()));
|
||
timeline.appendChild(actRow);
|
||
|
||
// Hidden file input for project load
|
||
this._projectFileInput = document.createElement("input");
|
||
Object.assign(this._projectFileInput, { type: "file", accept: ".json" });
|
||
this._projectFileInput.style.display = "none";
|
||
this._projectFileInput.addEventListener("change", e => {
|
||
const f = e.target.files[0]; if (!f) return;
|
||
this._loadProject(f);
|
||
this._projectFileInput.value = "";
|
||
});
|
||
document.body.appendChild(this._projectFileInput);
|
||
|
||
document.body.appendChild(this.overlay);
|
||
|
||
// Viewport canvas events
|
||
this.canvas.addEventListener("mousedown", e => this._onCanvasMouseDown(e));
|
||
this.canvas.addEventListener("mousemove", e => this._onCanvasMouseMove(e));
|
||
this.canvas.addEventListener("mouseup", e => this._onCanvasMouseUp(e));
|
||
this.canvas.addEventListener("contextmenu", e => this._onCanvasContextMenu(e));
|
||
this.canvas.addEventListener("wheel", e => this._onCanvasWheel(e), { passive: false });
|
||
this.canvas.addEventListener("dblclick", () => { this.vpZoom=1; this.vpPanX=0; this.vpPanY=0; this.orbitZoom=1; this._renderFrame(this.currentFrame); });
|
||
|
||
// Graph canvas events
|
||
this.graphCanvas.addEventListener("mousedown", e => this._onGraphMouseDown(e));
|
||
this.graphCanvas.addEventListener("mousemove", e => this._onGraphMouseMove(e));
|
||
this.graphCanvas.addEventListener("mouseup", e => this._onGraphMouseUp(e));
|
||
this.graphCanvas.addEventListener("contextmenu", e => { e.preventDefault(); if (this._graphGrab) this._cancelGraphGrab(); if (this._graphScale) this._cancelGraphScale(); });
|
||
this.graphCanvas.addEventListener("wheel", e => this._onGraphWheel(e), { passive: false });
|
||
|
||
// Keyboard
|
||
this._onKeyDown = e => {
|
||
// F1 / ? — always open help, even when focus is in an input
|
||
if (e.key === "F1" || (e.key === "?" && !e.ctrlKey && !e.altKey)) {
|
||
e.preventDefault(); this._showHelp(); return;
|
||
}
|
||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||
|
||
// --- Universal shortcuts (work in any tab) ---
|
||
if (e.ctrlKey && !e.shiftKey && (e.key === "z" || e.key === "Z")) { e.preventDefault(); this._undo(); return; }
|
||
if (e.ctrlKey && (e.key === "y" || e.key === "Y" || (e.shiftKey && (e.key === "z" || e.key === "Z")))) { e.preventDefault(); this._redo(); return; }
|
||
if (e.key === " " || e.key === "Spacebar") { e.preventDefault(); this._togglePlay("forward"); return; }
|
||
if (e.key === "ArrowLeft") { e.preventDefault(); this._stopPlayback(); this._seekFrame(this.currentFrame - 1); return; }
|
||
if (e.key === "ArrowRight") { e.preventDefault(); this._stopPlayback(); this._seekFrame(this.currentFrame + 1); return; }
|
||
if (e.key === "End") { e.preventDefault(); this._stopPlayback(); this._seekFrame(this.frameCount - 1); return; }
|
||
|
||
// --- H — hide/show selected joint(s); Alt+H — show all ---
|
||
if ((e.key === "h" || e.key === "H") && !e.ctrlKey) {
|
||
e.preventDefault();
|
||
if (e.altKey) { this.hiddenLayers.clear(); this.hiddenGroups.clear(); }
|
||
else { for (const lbl of this.selectedJoints) { if (this.hiddenLayers.has(lbl)) this.hiddenLayers.delete(lbl); else this.hiddenLayers.add(lbl); } }
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
return;
|
||
}
|
||
|
||
// ====== GRAPH EDITOR shortcuts ======
|
||
if (this.activeTab === "graph") {
|
||
// Escape: cancel grab OR clear selection
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
if (this._graphGrab) { this._cancelGraphGrab(); return; }
|
||
this.graphSel.clear(); this._renderGraphEditor(); return;
|
||
}
|
||
// Swallow all keys during Grab or Scale (except universal)
|
||
if (this._graphGrab) {
|
||
if (e.key === "x" || e.key === "X") { e.preventDefault(); this._graphGrab.axisLock = this._graphGrab.axisLock === "time" ? null : "time"; this._applyGraphGrab(this._lastGraphMouse.x, this._lastGraphMouse.y); return; }
|
||
if (e.key === "y" || e.key === "Y") { e.preventDefault(); this._graphGrab.axisLock = this._graphGrab.axisLock === "value" ? null : "value"; this._applyGraphGrab(this._lastGraphMouse.x, this._lastGraphMouse.y); return; }
|
||
if (e.key === "Enter") { e.preventDefault(); this._confirmGraphGrab(); return; }
|
||
return;
|
||
}
|
||
if (this._graphScale) {
|
||
if (e.key === "x" || e.key === "X") { e.preventDefault(); this._graphScale.axisLock = this._graphScale.axisLock === "time" ? null : "time"; this._applyGraphScale(this._lastGraphMouse.x, this._lastGraphMouse.y); return; }
|
||
if (e.key === "y" || e.key === "Y") { e.preventDefault(); this._graphScale.axisLock = this._graphScale.axisLock === "value" ? null : "value"; this._applyGraphScale(this._lastGraphMouse.x, this._lastGraphMouse.y); return; }
|
||
if (e.key === "Enter") { e.preventDefault(); this._confirmGraphScale(); return; }
|
||
if (e.key === "Escape") { e.preventDefault(); this._cancelGraphScale(); return; }
|
||
return;
|
||
}
|
||
// G: grab | S: scale | O: smooth
|
||
if ((e.key === "g" || e.key === "G") && !e.ctrlKey) { e.preventDefault(); this._startGraphGrab(); return; }
|
||
if ((e.key === "s" || e.key === "S") && !e.ctrlKey) { e.preventDefault(); this._startGraphScale(); return; }
|
||
if ((e.key === "o" || e.key === "O") && !e.ctrlKey) { e.preventDefault(); this._smoothSelectedKfs(); return; }
|
||
// A / Ctrl+A: select all / deselect all
|
||
if ((e.key === "a" || e.key === "A") && !e.ctrlKey) {
|
||
e.preventDefault();
|
||
if (this.graphSel.size > 0) this.graphSel.clear();
|
||
else { for (const lbl of this._graphLabels()) for (const fi of this._getKeyframesForJoint(lbl)) for (const c of [0,1]) this.graphSel.add(`${fi}::${lbl}::${c}`); }
|
||
this._renderGraphEditor(); return;
|
||
}
|
||
if (e.ctrlKey && (e.key === "a" || e.key === "A")) {
|
||
e.preventDefault();
|
||
for (const lbl of this._graphLabels()) for (const fi of this._getKeyframesForJoint(lbl)) for (const c of [0,1]) this.graphSel.add(`${fi}::${lbl}::${c}`);
|
||
this._renderGraphEditor(); return;
|
||
}
|
||
// X / Delete: delete selected keyframes
|
||
if (e.key === "x" || e.key === "X" || e.key === "Delete") {
|
||
e.preventDefault(); this._deleteGraphSelected(); return;
|
||
}
|
||
// K / I: insert keyframe at current frame for active channels
|
||
if (e.key === "k" || e.key === "K" || e.key === "i" || e.key === "I") {
|
||
e.preventDefault(); this._insertKeyframeSelected(); return;
|
||
}
|
||
// . (period): frame selected keyframes in view
|
||
if (e.key === ".") {
|
||
e.preventDefault(); this._graphFitView(); this._renderGraphEditor(); return;
|
||
}
|
||
// Home: fit all frames in view
|
||
if (e.key === "Home") {
|
||
e.preventDefault();
|
||
this.graphViewport = { frameStart: 0, frameEnd: Math.max(1, this.frameCount-1), valMin: 0, valMax: Math.max(this.poseW, this.poseH) };
|
||
this._renderGraphEditor(); return;
|
||
}
|
||
// Ctrl+C: copy graph-selected KFs into clipboard (via selKfs bridge)
|
||
if (e.ctrlKey && (e.key === "c" || e.key === "C")) {
|
||
e.preventDefault();
|
||
const seen = new Set();
|
||
for (const key of this.graphSel) { const p = key.split("::"); seen.add(`${p[0]}::${p[1]}`); }
|
||
for (const id of seen) this.selKfs.add(id);
|
||
this._copySelectedKeyframes(); return;
|
||
}
|
||
// Ctrl+V: paste at current frame
|
||
if (e.ctrlKey && (e.key === "v" || e.key === "V")) { e.preventDefault(); this._pasteKeyframes(); return; }
|
||
// Tab: switch to dope sheet
|
||
if (e.key === "Tab") { e.preventDefault(); this._switchTab("dope"); return; }
|
||
return; // don't fall through to dope-sheet shortcuts
|
||
}
|
||
|
||
// ====== DOPE SHEET shortcuts ======
|
||
if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
this._hideSegmentPopup(); this.selKfs.clear(); this._renderTrack(); return;
|
||
}
|
||
// Home: jump to first frame (in dope sheet context)
|
||
if (e.key === "Home") { e.preventDefault(); this._stopPlayback(); this._seekFrame(0); return; }
|
||
// Tab: switch to graph editor
|
||
if (e.key === "Tab") { e.preventDefault(); this._switchTab("graph"); return; }
|
||
// K / I: insert keyframe
|
||
if (e.key === "k" || e.key === "K" || e.key === "i" || e.key === "I") { e.preventDefault(); this._insertKeyframeSelected(); return; }
|
||
// X / Delete: delete selected keyframes (or KF at cursor if nothing selected and joint active)
|
||
if (e.key === "x" || e.key === "X" || e.key === "Delete") {
|
||
e.preventDefault();
|
||
if (this.selKfs.size > 0) this._deleteSelectedKeyframes();
|
||
else if (this.selectedJoint) this._deleteKeyframeSelected();
|
||
return;
|
||
}
|
||
// A: toggle select all / deselect all
|
||
if ((e.key === "a" || e.key === "A") && !e.ctrlKey) {
|
||
e.preventDefault();
|
||
if (this.selKfs.size > 0) { this.selKfs.clear(); this._renderTrack(); }
|
||
else this._selectAllKeyframes();
|
||
return;
|
||
}
|
||
// Ctrl+A: select all
|
||
if (e.ctrlKey && (e.key === "a" || e.key === "A")) { e.preventDefault(); this._selectAllKeyframes(); return; }
|
||
// Ctrl+C / Ctrl+V: copy/paste keyframes
|
||
if (e.ctrlKey && (e.key === "c" || e.key === "C")) { this._copySelectedKeyframes(); return; }
|
||
if (e.ctrlKey && (e.key === "v" || e.key === "V")) { this._pasteKeyframes(); return; }
|
||
};
|
||
window.addEventListener("keydown", this._onKeyDown);
|
||
|
||
// Resize
|
||
this._ro = new ResizeObserver(() => {
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
this.canvas.width = rect.width;
|
||
this.canvas.height = rect.height;
|
||
this._renderFrame(this.currentFrame);
|
||
});
|
||
this._ro.observe(this.canvas);
|
||
this._roGraph = new ResizeObserver(() => {
|
||
if (this.activeTab !== "graph") return;
|
||
const rect = this.graphCanvas.getBoundingClientRect();
|
||
this.graphCanvas.width = rect.width;
|
||
this.graphCanvas.height = rect.height;
|
||
this._graphAutoFitRange();
|
||
this._renderGraphEditor();
|
||
});
|
||
this._roGraph.observe(this.graphCanvas);
|
||
|
||
// Init tab state
|
||
this._switchTab("dope");
|
||
}
|
||
|
||
_mkBtn(text, fn, bg = THEME.bgBtn, title = "") {
|
||
const b = document.createElement("button");
|
||
b.textContent = text;
|
||
if (title) b.title = title;
|
||
_styleButton(b, { bg });
|
||
if (fn) b.addEventListener("click", fn);
|
||
return b;
|
||
}
|
||
_mkLabel(text) {
|
||
const l = document.createElement("div");
|
||
l.style.cssText = `font-size:10px;color:${THEME.textMute};letter-spacing:0.04em;text-transform:uppercase;margin:6px 0 2px;`;
|
||
l.textContent = text.replace(/^─+\s*|\s*─+$/g, "").trim();
|
||
return l;
|
||
}
|
||
/** Sidebar section header — flat label with accent underline (replaces collapsibles). */
|
||
_mkSidebarHeader(text) {
|
||
const wrap = document.createElement("div");
|
||
wrap.style.cssText = `display:flex;align-items:center;gap:6px;margin:10px 0 4px;padding-bottom:3px;border-bottom:1px solid ${THEME.border};`;
|
||
const accent = document.createElement("span");
|
||
accent.style.cssText = `width:3px;height:11px;background:${THEME.accent};border-radius:2px;flex-shrink:0;`;
|
||
const label = document.createElement("span");
|
||
label.style.cssText = `font-size:10px;color:${THEME.text};font-weight:600;letter-spacing:0.06em;text-transform:uppercase;`;
|
||
label.textContent = text;
|
||
wrap.appendChild(accent); wrap.appendChild(label);
|
||
return wrap;
|
||
}
|
||
/** Collapsible sidebar section. Returns { section, body } — append controls into `body`.
|
||
* Open/closed state persists in this._openSections by key. */
|
||
_mkSection(title, key, defaultOpen = true) {
|
||
if (!this._openSections) this._openSections = new Set();
|
||
if (defaultOpen && !this._openSections.has(`__init__:${key}`)) {
|
||
this._openSections.add(key);
|
||
this._openSections.add(`__init__:${key}`);
|
||
}
|
||
const section = document.createElement("div");
|
||
section.style.cssText = `border:1px solid ${THEME.border};border-radius:5px;background:${THEME.bgPanel};margin-top:6px;overflow:hidden;`;
|
||
const header = document.createElement("div");
|
||
Object.assign(header.style, {
|
||
display: "flex", alignItems: "center", padding: "5px 8px",
|
||
background: THEME.bgGroup, fontSize: "11px", fontWeight: "600",
|
||
color: THEME.text, cursor: "pointer", userSelect: "none",
|
||
letterSpacing: "0.04em", textTransform: "uppercase",
|
||
borderBottom: `1px solid ${THEME.border}`,
|
||
});
|
||
const caret = document.createElement("span");
|
||
caret.style.cssText = "width:12px;display:inline-block;color:" + THEME.textDim + ";";
|
||
const titleSpan = document.createElement("span");
|
||
titleSpan.textContent = title;
|
||
header.appendChild(caret); header.appendChild(titleSpan);
|
||
const body = document.createElement("div");
|
||
body.style.cssText = "padding:6px 8px;display:flex;flex-direction:column;gap:4px;";
|
||
section.appendChild(header); section.appendChild(body);
|
||
const apply = () => {
|
||
const open = this._openSections.has(key);
|
||
caret.textContent = open ? "▼" : "▶";
|
||
body.style.display = open ? "flex" : "none";
|
||
header.style.borderBottomColor = open ? THEME.border : "transparent";
|
||
};
|
||
apply();
|
||
header.addEventListener("click", () => {
|
||
if (this._openSections.has(key)) this._openSections.delete(key);
|
||
else this._openSections.add(key);
|
||
apply();
|
||
});
|
||
return { section, body };
|
||
}
|
||
_mkTransportBtn(text, title, fn) {
|
||
const b = document.createElement("button");
|
||
b.textContent = text; b.title = title;
|
||
_styleButton(b, { bg: THEME.bgBtn, height: THEME.btnH });
|
||
Object.assign(b.style, { fontSize: "13px", minWidth: "28px", padding: "0 7px" });
|
||
b.addEventListener("click", fn);
|
||
return b;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Tab switching
|
||
// -----------------------------------------------------------------------
|
||
_switchTab(id) {
|
||
this.activeTab = id;
|
||
this._applyTabAndMode();
|
||
}
|
||
|
||
_switchDataMode(mode) {
|
||
this.dataMode = mode;
|
||
for (const [id, btn] of Object.entries(this.dataModeBtns)) {
|
||
const active = id === mode;
|
||
btn.style.background = active ? THEME.accent : THEME.bgPanel;
|
||
btn.style.color = active ? "#fff" : THEME.textDim;
|
||
}
|
||
this._applyTabAndMode();
|
||
}
|
||
|
||
/** Apply the current (activeTab, dataMode) combination to the timeline panels.
|
||
* DWpose and NLF share the same dope/graph widgets — _getLayerDefs / _graphLabels
|
||
* swap rows by dataMode, so we just rebuild the timeline whenever the mode changes. */
|
||
_applyTabAndMode() {
|
||
const inDope = this.activeTab === "dope";
|
||
const inGraph = this.activeTab === "graph";
|
||
|
||
this.dopePanel.style.display = "flex";
|
||
this.trackWrapper.style.display = inDope ? "block" : "none";
|
||
this.graphCanvas.style.display = inGraph ? "block" : "none";
|
||
this.graphRightPanel.style.display = inGraph ? "flex" : "none";
|
||
// Keep kfBar visible in both modes so the interp buttons remain reachable
|
||
// from the graph editor (they operate on graph selection too). Hide only the
|
||
// dope-only buttons in graph mode — the graph right panel already has those.
|
||
if (this.kfBar) this.kfBar.style.display = "flex";
|
||
if (this._kfBarDopeOnly) {
|
||
for (const b of this._kfBarDopeOnly) {
|
||
if (b) b.style.display = inDope ? "" : "none";
|
||
}
|
||
}
|
||
|
||
for (const [tid, btn] of Object.entries(this.tabBtns)) {
|
||
const active = tid === this.activeTab;
|
||
btn.style.color = active ? "#d4ddf4" : "#5a6a88";
|
||
btn.style.borderColor = active ? "#5bc4ff" : "transparent";
|
||
btn.style.background = active ? "#141e38" : "none";
|
||
}
|
||
if (this.dataModeBtns) {
|
||
for (const [id, btn] of Object.entries(this.dataModeBtns)) {
|
||
const active = id === this.dataMode;
|
||
btn.style.background = active ? THEME.accent : THEME.bgPanel;
|
||
btn.style.color = active ? "#fff" : THEME.textDim;
|
||
}
|
||
}
|
||
|
||
if (inDope) this._refreshTimeline();
|
||
if (inGraph) {
|
||
requestAnimationFrame(() => {
|
||
const rect = this.graphCanvas.getBoundingClientRect();
|
||
if (rect.width > 0) {
|
||
this.graphCanvas.width = Math.round(rect.width);
|
||
this.graphCanvas.height = Math.round(rect.height);
|
||
}
|
||
this._graphAutoFitRange(); this._renderGraphEditor();
|
||
});
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Playback
|
||
// -----------------------------------------------------------------------
|
||
_togglePlay(direction) {
|
||
if (this.playState === direction) this._stopPlayback();
|
||
else this._startPlayback(direction);
|
||
}
|
||
_startPlayback(direction) {
|
||
this._stopPlayback();
|
||
this.playState = direction;
|
||
this._playDir = direction;
|
||
this._playLastT = null;
|
||
this._updatePlayBtns();
|
||
const msPerFrame = 1000 / this.playFPS;
|
||
const tick = (ts) => {
|
||
if (this.playState !== direction) return;
|
||
if (this._playLastT === null) this._playLastT = ts;
|
||
const elapsed = ts - this._playLastT;
|
||
if (elapsed >= msPerFrame) {
|
||
this._playLastT = ts - (elapsed % msPerFrame);
|
||
const lo = this.frameRangeStart;
|
||
const hi = this.frameRangeEnd || this.frameCount - 1;
|
||
let next = this.currentFrame + (this._playDir === "forward" ? 1 : -1);
|
||
if (next > hi) {
|
||
if (this.loopMode === "cycle") { next = lo; }
|
||
else if (this.loopMode === "pingpong") { this._playDir = "backward"; next = Math.max(lo, hi - 1); }
|
||
else { this._stopPlayback(); return; }
|
||
} else if (next < lo) {
|
||
if (this.loopMode === "cycle") { next = hi; }
|
||
else if (this.loopMode === "pingpong") { this._playDir = "forward"; next = Math.min(hi, lo + 1); }
|
||
else { this._stopPlayback(); return; }
|
||
}
|
||
this._seekFrame(next);
|
||
}
|
||
this._playRAF = requestAnimationFrame(tick);
|
||
};
|
||
this._playRAF = requestAnimationFrame(tick);
|
||
}
|
||
_stopPlayback() {
|
||
if (this._playRAF) { cancelAnimationFrame(this._playRAF); this._playRAF = null; }
|
||
this._playLastT = null; this.playState = "stopped"; this._playDir = null;
|
||
this._updatePlayBtns();
|
||
}
|
||
_updatePlayBtns() {
|
||
if (!this.playFwdBtn || !this.playBwdBtn) return;
|
||
const fa = this.playState === "forward", ba = this.playState === "backward";
|
||
this.playFwdBtn.textContent = fa ? "⏸" : "▶";
|
||
this.playFwdBtn.title = fa ? "Pause" : "Play Forward";
|
||
Object.assign(this.playFwdBtn.style, { background: fa?"#1a4a2a":"#1e1e30", borderColor: fa?"#3aaa6a":"#3a3a4a", color: fa?"#7affaa":"#ccc" });
|
||
this.playBwdBtn.textContent = ba ? "⏸" : "◀";
|
||
this.playBwdBtn.title = ba ? "Pause" : "Play Backwards";
|
||
Object.assign(this.playBwdBtn.style, { background: ba?"#1a4a2a":"#1e1e30", borderColor: ba?"#3aaa6a":"#3a3a4a", color: ba?"#7affaa":"#ccc" });
|
||
}
|
||
|
||
_updateInterpBtns() {
|
||
// Show the selected segment's mode (dope-sheet or graph selection).
|
||
// If no selection, show the active segment at currentFrame (read-only indicator).
|
||
// Buttons are only clickable when something is selected.
|
||
let effectiveMode = null;
|
||
let isPerSegment = false;
|
||
|
||
if (this.selKfs.size > 0) {
|
||
const [fStr, label] = [...this.selKfs][0].split("::");
|
||
const override = this.tweens[parseInt(fStr)]?.[label];
|
||
if (override) { effectiveMode = override; isPerSegment = true; }
|
||
} else if (this.graphSel?.size > 0) {
|
||
const parts = [...this.graphSel][0].split("::");
|
||
const override = this.tweens[parseInt(parts[0])]?.[parts[1]];
|
||
if (override) { effectiveMode = override; isPerSegment = true; }
|
||
} else {
|
||
// Read-only: show what segment the playhead is inside, if any
|
||
const fi = this.currentFrame;
|
||
let found = false;
|
||
for (const [tFi, tMap] of Object.entries(this.tweens)) {
|
||
if (found) break;
|
||
const tfi = parseInt(tFi);
|
||
if (tfi > fi) continue;
|
||
for (const [label, mode] of Object.entries(tMap)) {
|
||
const kfs = this._getKeyframesForJoint(label);
|
||
const idx = kfs.indexOf(tfi);
|
||
if (idx >= 0 && idx + 1 < kfs.length && kfs[idx + 1] > fi) {
|
||
effectiveMode = mode; isPerSegment = true; found = true; break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const hasSel = this.selKfs.size > 0 || this.graphSel?.size > 0;
|
||
for (const [val, btn] of Object.entries(this.interpBtns)) {
|
||
const active = val === effectiveMode;
|
||
btn.style.background = active ? "#2a1e4a" : "#1d2138";
|
||
btn.style.borderColor = active ? "#8860cc" : "#2c3352";
|
||
btn.style.color = active ? "#cc99ff" : (hasSel ? "#7888a8" : "#3a4560");
|
||
btn.style.cursor = hasSel ? "pointer" : "default";
|
||
btn.style.opacity = hasSel ? "1" : "0.45";
|
||
}
|
||
|
||
if (this.interpLabel) {
|
||
if (hasSel) {
|
||
this.interpLabel.textContent = "Seg:";
|
||
this.interpLabel.style.color = "#5bc4ff";
|
||
} else if (isPerSegment) {
|
||
this.interpLabel.textContent = "KF:";
|
||
this.interpLabel.style.color = "#7860aa";
|
||
} else {
|
||
this.interpLabel.textContent = "KF:";
|
||
this.interpLabel.style.color = "#4a5a78";
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Data fetch & init
|
||
// -----------------------------------------------------------------------
|
||
async _fetchData() {
|
||
try {
|
||
const r = await fetch(`/temporal-editor/data/${this.nodeId}`);
|
||
if (!r.ok) throw new Error(await r.text());
|
||
this._init(await r.json());
|
||
} catch (e) {
|
||
this.jointInfoEl.textContent = "Run the workflow first or Start a new Scene.";
|
||
}
|
||
}
|
||
_init(data) {
|
||
this.frameCount = data.frame_count || 0;
|
||
this.poseW = data.width || 512;
|
||
this.poseH = data.height || 512;
|
||
this.smoothWindow = data.smooth_window || 0;
|
||
this.interpolationMode = data.interpolation || "catmull_rom";
|
||
this.catmullTension = data.catmull_tension ?? 0.5;
|
||
this._canvasW = data.canvas_w || 0;
|
||
this._canvasH = data.canvas_h || 0;
|
||
this._contentHash = data._content_hash || "";
|
||
if (this._canvasWInp) this._canvasWInp.value = this._canvasW || this.poseW || "";
|
||
if (this._canvasHInp) this._canvasHInp.value = this._canvasH || this.poseH || "";
|
||
|
||
this.frames = {};
|
||
for (const [k, v] of Object.entries(data.frames || {}))
|
||
this.frames[parseInt(k)] = v;
|
||
this.overrides = {};
|
||
for (const [k, v] of Object.entries(data.overrides || {}))
|
||
this.overrides[parseInt(k)] = v;
|
||
// Migrate legacy zDepth dict → overrides[fi][label][3]
|
||
for (const [k, v] of Object.entries(data.z_depth || {})) {
|
||
const fi = parseInt(k);
|
||
for (const [label, z] of Object.entries(v)) {
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const ov = this.overrides[fi][label];
|
||
if (Array.isArray(ov)) { if (ov[3] === undefined) ov[3] = z; }
|
||
else this.overrides[fi][label] = [0, 0, 1, z];
|
||
}
|
||
}
|
||
// Ensure all override entries have z at index [3] (default 0)
|
||
for (const fi of Object.keys(this.overrides)) {
|
||
const frame = this.overrides[fi];
|
||
for (const label of Object.keys(frame)) {
|
||
const ov = frame[label];
|
||
if (Array.isArray(ov) && ov.length === 3) ov.push(null);
|
||
}
|
||
}
|
||
this.tweens = {};
|
||
for (const [k, v] of Object.entries(data.tweens || {}))
|
||
this.tweens[parseInt(k)] = v;
|
||
this.refFrameOverrides = data.ref_frame_overrides || {};
|
||
|
||
this.scrubber.max = String(Math.max(0, this.frameCount - 1));
|
||
|
||
// Default range end to last frame so zone layout starts in linear (full) mode
|
||
if (this.frameRangeEnd === 0 && this.frameCount > 1) {
|
||
this.frameRangeEnd = this.frameCount - 1;
|
||
if (this.rangeEndInp) this.rangeEndInp.value = this.frameRangeEnd;
|
||
}
|
||
|
||
this._updateInterpBtns();
|
||
this.graphViewport = { frameStart: 0, frameEnd: Math.max(1, this.frameCount - 1), valMin: 0, valMax: Math.max(this.poseW, this.poseH) };
|
||
|
||
// Restore UI display state (experimental mode, viewports, opacity sliders, data mode)
|
||
if (data.experimental_mode !== undefined) {
|
||
const wantExp = !!data.experimental_mode;
|
||
if (wantExp !== this._experimentalMode) this._toggleExperimental();
|
||
// Override the panel layout/views that _toggleExperimental set, if we have saved ones
|
||
if (wantExp && Array.isArray(data.panel_views)) {
|
||
this._panelViews = [...data.panel_views];
|
||
this._setLayout(data.panel_layout || 4);
|
||
}
|
||
}
|
||
if (data.dwpose_alpha !== undefined) {
|
||
this._dwposeAlpha = data.dwpose_alpha;
|
||
if (this._dwposeAlphaSlider) {
|
||
this._dwposeAlphaSlider.value = String(this._dwposeAlpha);
|
||
this._dwposeAlphaSlider.dispatchEvent(new Event("input"));
|
||
}
|
||
}
|
||
if (data.nlf_alpha !== undefined) {
|
||
this._nlfAlpha = data.nlf_alpha;
|
||
if (this._nlfAlphaSlider) {
|
||
this._nlfAlphaSlider.value = String(this._nlfAlpha);
|
||
this._nlfAlphaSlider.dispatchEvent(new Event("input"));
|
||
}
|
||
}
|
||
if (data.data_mode) this._switchDataMode(data.data_mode);
|
||
|
||
this._refreshTimeline();
|
||
this._seekFrame(0);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Layer definitions
|
||
// -----------------------------------------------------------------------
|
||
_getLayerDefs() {
|
||
const rows = [], exp = this.expandedGroups;
|
||
const WRIST_SET = new Set(["body_4","body_7"]);
|
||
const addJoint = (label, name, color, group, index) => {
|
||
rows.push({ type:"joint", label, name, color, group, index });
|
||
if (this.expandedJoints.has(label)) {
|
||
rows.push({ type:"joint_detail", detail:"xy", label, color, group, index });
|
||
rows.push({ type:"joint_detail", detail:"z", label, color, group, index });
|
||
rows.push({ type:"joint_detail", detail:"zoffset", label, color, group, index });
|
||
rows.push({ type:"joint_detail", detail:"conf", label, color, group, index });
|
||
if (ROTATION_ENABLED && WRIST_SET.has(label)) {
|
||
rows.push({ type:"wrist_rotation", label, color:"#aa88cc", group, index });
|
||
}
|
||
}
|
||
};
|
||
|
||
if (this.dataMode === "nlf") {
|
||
// NLF mode: show only nlf_body rows (18 OpenPose joints, edited via overrides)
|
||
rows.push({ type: "group", id: "nlf_body", name: "NLF Body", color: NLF_BODY_COLOR });
|
||
if (exp.has("nlf_body"))
|
||
for (let i = 0; i < N_NLF_BODY; i++)
|
||
addJoint(`nlf_body_${i}`, JOINT_LABELS[i] || `j${i}`, NLF_BODY_COLOR, "nlf_body", i);
|
||
} else {
|
||
// DWpose mode: body / hands / face
|
||
rows.push({ type: "group", id: "body", name: "Body", color: "#555" });
|
||
if (exp.has("body")) for (let i=0;i<20;i++) addJoint(`body_${i}`, JOINT_LABELS[i], JOINT_COLORS[i]||"#aaa", "body", i);
|
||
rows.push({ type: "group", id: "rhand", name: "R Hand", color: RHAND_COLOR });
|
||
if (exp.has("rhand")) for (let i=0;i<21;i++) addJoint(`rhand_${i}`, HAND_JOINT_LABELS[i]||`f${i}`, RHAND_COLOR, "rhand", i);
|
||
rows.push({ type: "group", id: "lhand", name: "L Hand", color: LHAND_COLOR });
|
||
if (exp.has("lhand")) for (let i=0;i<21;i++) addJoint(`lhand_${i}`, HAND_JOINT_LABELS[i]||`f${i}`, LHAND_COLOR, "lhand", i);
|
||
rows.push({ type: "group", id: "face", name: "Face", color: FACE_COLOR });
|
||
if (exp.has("face")) for (let i=0;i<N_FACE;i++) addJoint(`face_${i}`, FACE_JOINT_LABELS[i], FACE_COLOR, "face", i);
|
||
}
|
||
|
||
// Camera group — shown in 3D mode or once a camera KF exists
|
||
if (this._experimentalMode || this._hasCameraKeyframes()) {
|
||
rows.push({ type: "group", id: "camera", name: "◎ Camera", color: "#cc8800" });
|
||
if (exp.has("camera")) {
|
||
// Camera channels are scalars — single value, not 2D pose points
|
||
const addCamScalar = (label, name, color, min, max, step, def, unit) =>
|
||
rows.push({ type: "camscalar", label, name, color, group: "camera",
|
||
min, max, step, def, unit: unit ?? "" });
|
||
addCamScalar("cam_x", "Pos X", "#ffaa44", -2, 2, 0.01, 0, "");
|
||
addCamScalar("cam_y", "Pos Y", "#ff8844", -2, 2, 0.01, 0, "");
|
||
addCamScalar("cam_z", "Pos Z", "#ff6633", 0.1, 10, 0.01, 1.0, "");
|
||
addCamScalar("cam_roll", "Roll", "#aabb44", -180, 180, 0.5, 0, "°");
|
||
addCamScalar("cam_tilt", "Tilt", "#88cc44", -89, 89, 0.5, 0, "°");
|
||
addCamScalar("cam_pan", "Pan", "#44ccaa", -180, 180, 0.5, 0, "°");
|
||
addCamScalar("cam_fov", "FOV", "#ffcc44", 12, 200, 1, 60, "°");
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Timeline (dope sheet)
|
||
// -----------------------------------------------------------------------
|
||
_refreshTimeline() {
|
||
this._buildLayerPanel(); this._renderTrack(); this._renderFrame(this.currentFrame); this._updateCamBtnStyle();
|
||
}
|
||
_updateSidebarValues() {
|
||
if (!this._sidebarRefs) return;
|
||
const fi = this.currentFrame;
|
||
for (const [label, ref] of Object.entries(this._sidebarRefs)) {
|
||
const { valInp, kfBtn, row } = ref;
|
||
const hasKf = !!this.overrides[fi]?.[label];
|
||
kfBtn.textContent = hasKf ? "◆" : "◇";
|
||
kfBtn.style.color = hasKf ? row.color : "#445";
|
||
kfBtn.title = hasKf ? "Remove KF at this frame" : "Add KF at this frame";
|
||
if (valInp) {
|
||
const v = this.overrides[fi]?.[label]?.[0]
|
||
?? this._interpolateJoint(label, fi)?.[0]
|
||
?? row.def ?? 0;
|
||
valInp.value = v.toFixed(row.step < 0.1 ? 3 : 2);
|
||
}
|
||
}
|
||
}
|
||
|
||
_updateCamBtnStyle() {
|
||
if (!this._addCamBtn) return;
|
||
const hasCam = this._hasCameraKeyframes();
|
||
this._addCamBtn.style.background = hasCam ? "#1e1e28" : "#0e2a12";
|
||
this._addCamBtn.style.color = hasCam ? "#445566" : "#88cc88";
|
||
this._addCamBtn.style.cursor = hasCam ? "default" : "pointer";
|
||
this._addCamBtn.title = hasCam
|
||
? "Camera already added to dope sheet"
|
||
: "Add a camera — adds ◎ Camera group to the dope sheet.";
|
||
}
|
||
|
||
_buildLayerPanel() {
|
||
this.layerPanel.innerHTML = "";
|
||
this._detailInputs = {}; // clear stale input refs; rebuilt below
|
||
this._sidebarRefs = {}; // camscalar valInp + kfBtn refs for live value updates
|
||
const rows = this._getLayerDefs();
|
||
rows.forEach((row, ri) => {
|
||
const el = document.createElement("div");
|
||
Object.assign(el.style, {
|
||
height:`${ROW_H}px`, display:"flex", alignItems:"center",
|
||
padding:"0 6px", fontSize:"11px", cursor:"pointer",
|
||
overflow:"hidden", whiteSpace:"nowrap", userSelect:"none", boxSizing:"border-box",
|
||
});
|
||
if (row.type === "group") {
|
||
const isHiddenGrp = this.hiddenGroups.has(row.id);
|
||
el.style.background = THEME.bgGroup;
|
||
el.style.fontWeight = "600";
|
||
el.style.color = isHiddenGrp ? THEME.textMute : THEME.text;
|
||
el.style.cursor = "pointer";
|
||
el.style.borderTop = `1px solid ${THEME.border}`;
|
||
el.style.borderLeft = `3px solid ${row.color || THEME.accent}`;
|
||
el.style.transition = "background 90ms ease";
|
||
el.addEventListener("mouseenter", () => { if (!isHiddenGrp) el.style.background = "#222a44"; });
|
||
el.addEventListener("mouseleave", () => { el.style.background = THEME.bgGroup; });
|
||
// Expand arrow + name
|
||
const nameSpan = document.createElement("span");
|
||
nameSpan.style.flex = "1";
|
||
nameSpan.style.letterSpacing = "0.02em";
|
||
nameSpan.textContent = `${this.expandedGroups.has(row.id)?"▼":"▶"} ${row.name}`;
|
||
el.appendChild(nameSpan);
|
||
el.title = "Click: expand/collapse | Shift+Click: select all joints in group";
|
||
// Eye button for group
|
||
const eyeGrpBtn = document.createElement("button");
|
||
eyeGrpBtn.textContent = isHiddenGrp ? "🙈" : "👁";
|
||
eyeGrpBtn.title = isHiddenGrp ? "Show group in viewport" : "Hide group in viewport";
|
||
Object.assign(eyeGrpBtn.style, {
|
||
background:"none", border:"none", cursor:"pointer", fontSize:"11px",
|
||
padding:"0 3px", flexShrink:"0", opacity: "1",
|
||
});
|
||
const _toggleGrpEye = () => {
|
||
if (this.hiddenGroups.has(row.id)) this.hiddenGroups.delete(row.id);
|
||
else this.hiddenGroups.add(row.id);
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
};
|
||
eyeGrpBtn.addEventListener("click", e2 => { e2.stopPropagation(); _toggleGrpEye(); });
|
||
eyeGrpBtn.addEventListener("mouseenter", e2 => { if (e2.shiftKey) _toggleGrpEye(); });
|
||
el.appendChild(eyeGrpBtn);
|
||
if (row.id === "rhand" || row.id === "lhand") {
|
||
const isIk = this.handIkMode[row.id];
|
||
const ikBtn = document.createElement("button");
|
||
ikBtn.textContent = isIk ? "IK" : "FK";
|
||
ikBtn.title = isIk
|
||
? "IK: fingers follow wrist — click to switch to FK"
|
||
: "FK: joints move independently — click to switch to IK";
|
||
Object.assign(ikBtn.style, {
|
||
background: isIk ? "#1a3a2a" : "#1e1e2e",
|
||
border: `1px solid ${isIk ? "#44bb66" : "#334"}`,
|
||
color: isIk ? "#88ffaa" : "#667",
|
||
borderRadius: "3px", padding: "0 5px", fontSize: "9px",
|
||
cursor: "pointer", flexShrink: "0", lineHeight: "16px",
|
||
});
|
||
ikBtn.addEventListener("click", e2 => {
|
||
e2.stopPropagation();
|
||
this.handIkMode[row.id] = !this.handIkMode[row.id];
|
||
this._refreshTimeline();
|
||
});
|
||
el.appendChild(ikBtn);
|
||
}
|
||
el.addEventListener("click", e => {
|
||
if (e.shiftKey) {
|
||
// Shift+click group: select ALL joints in this group
|
||
const groupRows = this._getLayerDefs().filter(r => r.type === "joint" && r.group === row.id);
|
||
for (const gr of groupRows) this.selectedJoints.add(gr.label);
|
||
if (groupRows.length > 0) {
|
||
this.selectedJoint = { group:groupRows[0].group, index:groupRows[0].index, label:groupRows[0].label };
|
||
this._updateJointInfo();
|
||
}
|
||
} else {
|
||
// Plain click: toggle expand/collapse
|
||
if (this.expandedGroups.has(row.id)) this.expandedGroups.delete(row.id);
|
||
else this.expandedGroups.add(row.id);
|
||
}
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
});
|
||
} else if (row.type === "joint_detail") {
|
||
// ---- Detail sub-row (X/Y or Conf) ----
|
||
el.style.background = "#090914";
|
||
el.style.borderLeft = `2px solid ${row.color}55`;
|
||
el.style.cursor = "default";
|
||
el.style.padding = "0 4px 0 10px";
|
||
el.style.gap = "3px";
|
||
|
||
if (!this._detailInputs[row.label]) this._detailInputs[row.label] = {};
|
||
|
||
// Read effective value at current frame
|
||
const detFd = this._getEffectiveFrame(this.currentFrame);
|
||
const detPts = _grpPts(detFd, row.group);
|
||
const detPt = detPts?.[row.index] ?? [0, 0, 0]; // conf=0 when joint not in detection
|
||
|
||
const writeDetail = (xOvr, yOvr, cOvr, zOvr) => {
|
||
const fi = this.currentFrame;
|
||
const fd2 = this._getEffectiveFrame(fi);
|
||
const gp = _grpPts(fd2, row.group);
|
||
const cur = gp?.[row.index] ?? [0, 0, 1, 0];
|
||
const curZ = cur[3] ?? null;
|
||
if (this._panelInputDragging) this._lazyPushUndo(); else this._pushUndo();
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const z = zOvr !== undefined ? zOvr : curZ;
|
||
this.overrides[fi][row.label] = [
|
||
xOvr !== undefined ? xOvr : cur[0],
|
||
yOvr !== undefined ? yOvr : cur[1],
|
||
cOvr !== undefined ? Math.max(0, Math.min(1, cOvr)) : (cur[2] ?? 1),
|
||
z,
|
||
];
|
||
this._renderFrame(fi); this._renderTrack(); this._updateDetailPanels();
|
||
};
|
||
|
||
const mkLbl = (t, w) => {
|
||
const s = document.createElement("span");
|
||
s.textContent = t;
|
||
s.style.cssText = `font-size:9px;color:#667;flex-shrink:0;min-width:${w}px;`;
|
||
return s;
|
||
};
|
||
const mkNumInp = (val, step) => {
|
||
const inp = document.createElement("input");
|
||
Object.assign(inp, { type:"number", value: val, step: String(step) });
|
||
Object.assign(inp.style, {
|
||
width:"46px", flex:"1", background:"#141428", color:"#aabbdd",
|
||
border:"1px solid #2a2a44", borderRadius:"2px",
|
||
padding:"0 3px", fontSize:"9px", height:"15px", boxSizing:"border-box",
|
||
cursor:"ew-resize",
|
||
});
|
||
inp.addEventListener("click", e2 => e2.stopPropagation());
|
||
let startX, startVal, moved;
|
||
const onMove = (ev) => {
|
||
const dx = ev.clientX - startX;
|
||
if (!moved && Math.abs(dx) < 3) return;
|
||
if (!moved) { moved = true; inp.blur(); document.body.style.cursor = "ew-resize"; }
|
||
inp.value = parseFloat((startVal + dx * step).toFixed(6));
|
||
if (!this._panelInputDragging) this._panelInputDragging = true;
|
||
inp.dispatchEvent(new Event("change"));
|
||
};
|
||
const onUp = () => {
|
||
document.removeEventListener("mousemove", onMove);
|
||
document.removeEventListener("mouseup", onUp);
|
||
document.body.style.cursor = "";
|
||
this._panelInputDragging = false;
|
||
if (moved) this._lazyPushUndo();
|
||
else inp.select();
|
||
};
|
||
inp.addEventListener("mousedown", e2 => {
|
||
e2.stopPropagation();
|
||
startX = e2.clientX;
|
||
startVal = parseFloat(inp.value) || 0;
|
||
moved = false;
|
||
this._armUndo();
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
});
|
||
return inp;
|
||
};
|
||
|
||
if (row.detail === "xy") {
|
||
const xInp = mkNumInp(detPt[0].toFixed(1), 1);
|
||
const yInp = mkNumInp(detPt[1].toFixed(1), 1);
|
||
xInp.addEventListener("change", () => {
|
||
const v = parseFloat(xInp.value);
|
||
if (!isNaN(v)) writeDetail(v, undefined, undefined, undefined);
|
||
else xInp.value = detPt[0].toFixed(1);
|
||
});
|
||
yInp.addEventListener("change", () => {
|
||
const v = parseFloat(yInp.value);
|
||
if (!isNaN(v)) writeDetail(undefined, v, undefined, undefined);
|
||
else yInp.value = detPt[1].toFixed(1);
|
||
});
|
||
el.append(mkLbl("X", 10), xInp, mkLbl("Y", 10), yInp);
|
||
this._detailInputs[row.label].xInp = xInp;
|
||
this._detailInputs[row.label].yInp = yInp;
|
||
} else if (row.detail === "z") {
|
||
const zVal = detPt[3] ?? this.overrides[this.currentFrame]?.[row.label]?.[3] ?? 0;
|
||
const zInp = mkNumInp(zVal.toFixed(3), 0.01);
|
||
zInp.addEventListener("change", () => {
|
||
const v = parseFloat(zInp.value);
|
||
if (!isNaN(v)) writeDetail(undefined, undefined, undefined, v);
|
||
else zInp.value = zVal.toFixed(3);
|
||
});
|
||
el.append(mkLbl("Z", 10), zInp);
|
||
this._detailInputs[row.label].zInp = zInp;
|
||
} else if (row.detail === "zoffset") {
|
||
const offVal = this.zGlobalOffset[row.label] ?? 0;
|
||
const offInp = mkNumInp(offVal.toFixed(3), 0.01);
|
||
offInp.style.color = "#88ddaa";
|
||
offInp.title = "Global Z offset — shifts this joint's depth across all frames";
|
||
offInp.addEventListener("change", () => {
|
||
const v = parseFloat(offInp.value);
|
||
if (!isNaN(v)) {
|
||
this.zGlobalOffset[row.label] = v;
|
||
this._renderFrame(this.currentFrame);
|
||
} else offInp.value = (this.zGlobalOffset[row.label] ?? 0).toFixed(3);
|
||
});
|
||
el.append(mkLbl("Z+", 14), offInp);
|
||
this._detailInputs[row.label].zOffsetInp = offInp;
|
||
} else {
|
||
// conf row — show as 0–100 percentage for readability
|
||
const pct = ((detPt[2] ?? 1) * 100).toFixed(0);
|
||
const confInp = mkNumInp(pct, 1);
|
||
confInp.min = "0"; confInp.max = "100";
|
||
confInp.addEventListener("change", () => {
|
||
const v = parseFloat(confInp.value);
|
||
if (!isNaN(v)) writeDetail(undefined, undefined, v / 100);
|
||
else confInp.value = ((detPt[2]??1)*100).toFixed(0);
|
||
});
|
||
el.append(mkLbl("Conf %", 38), confInp);
|
||
this._detailInputs[row.label].confInp = confInp;
|
||
}
|
||
} else if (row.type === "wrist_rotation") {
|
||
// ---- Wrist rotation sub-row: Rx Ry Rz inline ----
|
||
el.style.background = "#0d0d14";
|
||
el.style.borderLeft = "2px solid #aa88cc77";
|
||
el.style.padding = "0 4px 0 10px";
|
||
el.style.gap = "3px";
|
||
el.style.cursor = "default";
|
||
const mkRotLbl = (t) => {
|
||
const s = document.createElement("span");
|
||
s.textContent = t;
|
||
s.style.cssText = "font-size:9px;color:#667;flex-shrink:0;min-width:12px;";
|
||
return s;
|
||
};
|
||
if (!this._rotInputs) this._rotInputs = {};
|
||
if (!this._rotInputs[row.label]) this._rotInputs[row.label] = {};
|
||
const mkRotInp = (ch, color) => {
|
||
const key = `${row.label}::${ch}`;
|
||
const val = this.overrides[this.currentFrame]?.[key]
|
||
?? this._interpolateChannel(row.label, ch, this.currentFrame) ?? 0;
|
||
const inp = document.createElement("input");
|
||
inp.type = "number"; inp.value = val.toFixed(1); inp.step = "1";
|
||
inp.min = "-180"; inp.max = "180";
|
||
Object.assign(inp.style, {
|
||
width:"40px", flex:"1", background:"#141428", color,
|
||
border:"1px solid #2a2a44", borderRadius:"2px",
|
||
padding:"0 2px", fontSize:"9px", height:"15px", boxSizing:"border-box",
|
||
});
|
||
inp.addEventListener("click", e2 => e2.stopPropagation());
|
||
inp.addEventListener("mousedown", e2 => e2.stopPropagation());
|
||
inp.addEventListener("change", () => {
|
||
const v = parseFloat(inp.value) || 0;
|
||
if (!this.overrides[this.currentFrame]) this.overrides[this.currentFrame] = {};
|
||
this.overrides[this.currentFrame][key] = v;
|
||
this._renderFrame(this.currentFrame);
|
||
});
|
||
this._rotInputs[row.label][ch] = inp;
|
||
return inp;
|
||
};
|
||
const degSpan2 = document.createElement("span");
|
||
degSpan2.textContent = "°";
|
||
degSpan2.style.cssText = "color:#555;font-size:9px;padding-left:1px;flex-shrink:0;";
|
||
el.append(mkRotLbl("Rz"), mkRotInp("rz","#4488ff"), degSpan2);
|
||
} else if (row.type === "camscalar") {
|
||
// ---- Camera scalar channel row — single drag value ----
|
||
el.style.background = "#0e0e1c";
|
||
el.style.borderLeft = `3px solid ${row.color}`;
|
||
el.style.padding = "0 6px 0 8px";
|
||
el.style.gap = "4px"; el.style.cursor = "default";
|
||
|
||
const dot2 = document.createElement("span");
|
||
dot2.style.cssText = `display:inline-block;width:6px;height:6px;border-radius:50%;background:${row.color};flex-shrink:0;`;
|
||
el.appendChild(dot2);
|
||
|
||
const nameLbl = document.createElement("span");
|
||
nameLbl.style.cssText = "font-size:10px;color:#99aacc;flex-shrink:0;min-width:36px;";
|
||
nameLbl.textContent = row.name;
|
||
el.appendChild(nameLbl);
|
||
|
||
const curVal = this.overrides[this.currentFrame]?.[row.label]?.[0]
|
||
?? this._interpolateJoint(row.label, this.currentFrame)?.[0] ?? row.def ?? 0;
|
||
const valInp = document.createElement("input");
|
||
Object.assign(valInp, { type: "number", value: curVal.toFixed(row.step < 0.1 ? 3 : 2),
|
||
step: String(row.step ?? 0.01) });
|
||
if (row.min !== undefined) valInp.min = String(row.min);
|
||
if (row.max !== undefined) valInp.max = String(row.max);
|
||
Object.assign(valInp.style, {
|
||
flex: "1", background: "#141428", color: "#aabbdd",
|
||
border: "1px solid #2a2a44", borderRadius: "2px",
|
||
padding: "0 3px", fontSize: "11px", height: "17px", boxSizing: "border-box",
|
||
cursor: "ew-resize",
|
||
});
|
||
valInp.addEventListener("click", e2 => e2.stopPropagation());
|
||
|
||
const writeCamScalar = (v) => {
|
||
const fi = this.currentFrame;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
if (this._panelInputDragging) this._lazyPushUndo(); else this._pushUndo();
|
||
const prev = this.overrides[fi][row.label];
|
||
this.overrides[fi][row.label] = [v, prev?.[1]??0, 1, prev?.[3]??0];
|
||
this._renderFrame(fi); this._renderTrack();
|
||
};
|
||
|
||
let scStartX, scStartVal, scMoved;
|
||
const scOnMove = (ev) => {
|
||
const dx = ev.clientX - scStartX;
|
||
if (!scMoved && Math.abs(dx) < 3) return;
|
||
if (!scMoved) { scMoved = true; valInp.blur(); document.body.style.cursor = "ew-resize"; }
|
||
let nv = scStartVal + dx * (row.step ?? 0.01);
|
||
if (row.min !== undefined) nv = Math.max(row.min, nv);
|
||
if (row.max !== undefined) nv = Math.min(row.max, nv);
|
||
valInp.value = nv.toFixed(row.step < 0.1 ? 3 : 2);
|
||
if (!this._panelInputDragging) this._panelInputDragging = true;
|
||
valInp.dispatchEvent(new Event("change"));
|
||
};
|
||
const scOnUp = () => {
|
||
document.removeEventListener("mousemove", scOnMove);
|
||
document.removeEventListener("mouseup", scOnUp);
|
||
document.body.style.cursor = "";
|
||
this._panelInputDragging = false;
|
||
if (scMoved) this._lazyPushUndo(); else valInp.select();
|
||
};
|
||
valInp.addEventListener("mousedown", e2 => {
|
||
e2.stopPropagation();
|
||
scStartX = e2.clientX; scStartVal = parseFloat(valInp.value) || 0; scMoved = false;
|
||
this._armUndo();
|
||
document.addEventListener("mousemove", scOnMove);
|
||
document.addEventListener("mouseup", scOnUp);
|
||
});
|
||
valInp.addEventListener("change", () => {
|
||
let v = parseFloat(valInp.value);
|
||
if (isNaN(v)) { valInp.value = curVal.toFixed(row.step < 0.1 ? 3 : 2); return; }
|
||
if (row.min !== undefined) v = Math.max(row.min, v);
|
||
if (row.max !== undefined) v = Math.min(row.max, v);
|
||
writeCamScalar(v);
|
||
});
|
||
el.appendChild(valInp);
|
||
|
||
if (row.unit) {
|
||
const uLbl = document.createElement("span");
|
||
uLbl.style.cssText = "font-size:9px;color:#556;flex-shrink:0;padding-left:2px;";
|
||
uLbl.textContent = row.unit;
|
||
el.appendChild(uLbl);
|
||
}
|
||
|
||
// Keyframe diamond indicator
|
||
const hasKf = !!this.overrides[this.currentFrame]?.[row.label];
|
||
const kfBtn = document.createElement("span");
|
||
kfBtn.textContent = hasKf ? "◆" : "◇";
|
||
kfBtn.style.cssText = `color:${hasKf ? row.color : "#445"};font-size:11px;flex-shrink:0;cursor:pointer;padding:0 2px;`;
|
||
kfBtn.title = hasKf ? "Remove KF at this frame" : "Add KF at this frame";
|
||
kfBtn.addEventListener("click", e2 => {
|
||
e2.stopPropagation();
|
||
const fi = this.currentFrame;
|
||
if (this.overrides[fi]?.[row.label]) {
|
||
delete this.overrides[fi][row.label];
|
||
if (Object.keys(this.overrides[fi]).length === 0) delete this.overrides[fi];
|
||
} else {
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const v = this._interpolateJoint(row.label, fi)?.[0] ?? row.def ?? 0;
|
||
this.overrides[fi][row.label] = [v, 0, 1, 0];
|
||
}
|
||
this._refreshTimeline(); this._renderFrame(fi);
|
||
});
|
||
el.appendChild(kfBtn);
|
||
this._sidebarRefs[row.label] = { valInp, kfBtn, row };
|
||
// Click row → select this channel for the graph editor
|
||
el.style.cursor = "pointer";
|
||
const isSel = this._selectedCamLabels?.has(row.label);
|
||
if (isSel) el.style.background = "#1a2a1a";
|
||
el.addEventListener("click", (e3) => {
|
||
e3.stopPropagation();
|
||
if (!this._selectedCamLabels) this._selectedCamLabels = new Set();
|
||
if (e3.ctrlKey) {
|
||
if (this._selectedCamLabels.has(row.label)) this._selectedCamLabels.delete(row.label);
|
||
else this._selectedCamLabels.add(row.label);
|
||
} else {
|
||
this._selectedCamLabels.clear();
|
||
this._selectedCamLabels.add(row.label);
|
||
this.selectedJoints.clear(); this.selectedJoint = null;
|
||
}
|
||
this._switchTab("graph");
|
||
this._graphAutoFitRange(); this._renderGraphEditor(); this._refreshTimeline();
|
||
});
|
||
} else {
|
||
// ---- Normal joint row ----
|
||
const isActive = this.selectedJoint?.label === row.label;
|
||
const isMultiSel = this.selectedJoints.has(row.label);
|
||
const isLocked = this.lockedLayers.has(row.label);
|
||
const isHidden = this.hiddenLayers.has(row.label) || this.hiddenGroups.has(row.group);
|
||
el.style.background = isActive ? "#1a2a3a" : isMultiSel ? "#162230" : ri%2===0?"#111120":"#0e0e1c";
|
||
if (isLocked || isHidden) el.style.opacity = isHidden ? "0.4" : "0.55";
|
||
el.style.borderLeft = isMultiSel ? "2px solid #5599ff" : "2px solid transparent";
|
||
|
||
// Detail expand arrow (▶/▼)
|
||
const expandArrow = document.createElement("button");
|
||
expandArrow.textContent = this.expandedJoints.has(row.label) ? "▼" : "▶";
|
||
Object.assign(expandArrow.style, {
|
||
background:"none", border:"none", cursor:"pointer",
|
||
color: this.expandedJoints.has(row.label) ? "#88aacc" : "#445",
|
||
fontSize:"8px", padding:"0 3px 0 0", flexShrink:"0", lineHeight:"1",
|
||
});
|
||
expandArrow.title = "Show / hide position & confidence";
|
||
expandArrow.addEventListener("click", e2 => {
|
||
e2.stopPropagation();
|
||
if (this.expandedJoints.has(row.label)) this.expandedJoints.delete(row.label);
|
||
else this.expandedJoints.add(row.label);
|
||
this._refreshTimeline();
|
||
});
|
||
el.appendChild(expandArrow);
|
||
|
||
const dot = document.createElement("span");
|
||
Object.assign(dot.style, { display:"inline-block", width:"6px", height:"6px", borderRadius:"50%", background:row.color, marginRight:"5px", flexShrink:"0" });
|
||
el.appendChild(dot);
|
||
const nameEl = document.createElement("span");
|
||
nameEl.style.cssText = "flex:1;overflow:hidden;text-overflow:ellipsis;";
|
||
nameEl.textContent = row.name;
|
||
el.appendChild(nameEl);
|
||
// Eye button for individual joint
|
||
const eyeBtn = document.createElement("button");
|
||
const isJointHidden = this.hiddenLayers.has(row.label);
|
||
eyeBtn.textContent = isJointHidden ? "🙈" : "👁";
|
||
eyeBtn.title = isJointHidden ? "Show joint in viewport" : "Hide joint in viewport (editing aid — does not affect output)";
|
||
Object.assign(eyeBtn.style, {
|
||
background:"none", border:"none", cursor:"pointer", fontSize:"9px",
|
||
padding:"0 1px", flexShrink:"0", opacity: isJointHidden ? "1" : "0.25",
|
||
});
|
||
const _toggleJointEye = () => {
|
||
if (this.hiddenLayers.has(row.label)) this.hiddenLayers.delete(row.label);
|
||
else this.hiddenLayers.add(row.label);
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
};
|
||
eyeBtn.addEventListener("click", e2 => { e2.stopPropagation(); _toggleJointEye(); });
|
||
eyeBtn.addEventListener("mouseenter", e2 => { eyeBtn.style.opacity = "1"; if (e2.shiftKey) _toggleJointEye(); });
|
||
eyeBtn.addEventListener("mouseleave", () => { eyeBtn.style.opacity = this.hiddenLayers.has(row.label) ? "1" : "0.25"; });
|
||
el.appendChild(eyeBtn);
|
||
// Lock button
|
||
const lockBtn = document.createElement("button");
|
||
lockBtn.textContent = isLocked ? "🔒" : "🔓";
|
||
lockBtn.title = isLocked ? "Unlock layer" : "Lock layer (prevents editing)";
|
||
Object.assign(lockBtn.style, {
|
||
background:"none", border:"none", cursor:"pointer", fontSize:"10px",
|
||
padding:"0 2px", flexShrink:"0", opacity: isLocked ? "1" : "0.3",
|
||
});
|
||
const _toggleLock = () => {
|
||
if (this.lockedLayers.has(row.label)) this.lockedLayers.delete(row.label);
|
||
else this.lockedLayers.add(row.label);
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
};
|
||
lockBtn.addEventListener("click", e2 => { e2.stopPropagation(); _toggleLock(); });
|
||
lockBtn.addEventListener("mouseenter", e2 => { lockBtn.style.opacity = "1"; if (e2.shiftKey) _toggleLock(); });
|
||
lockBtn.addEventListener("mouseleave", () => { lockBtn.style.opacity = this.lockedLayers.has(row.label) ? "1" : "0.3"; });
|
||
el.appendChild(lockBtn);
|
||
// Keyframe diamond indicator
|
||
const hasKf = !!this.overrides[this.currentFrame]?.[row.label];
|
||
const kfBtn = document.createElement("span");
|
||
kfBtn.textContent = hasKf ? "◆" : "◇";
|
||
kfBtn.style.cssText = `color:${hasKf ? row.color : "#445"};font-size:11px;flex-shrink:0;cursor:pointer;padding:0 2px;`;
|
||
kfBtn.title = hasKf ? "Remove KF at this frame" : "Add KF at this frame";
|
||
kfBtn.addEventListener("click", e2 => {
|
||
e2.stopPropagation();
|
||
const fi = this.currentFrame;
|
||
if (this.overrides[fi]?.[row.label]) {
|
||
this._deleteKeyframe(row.label, fi);
|
||
} else {
|
||
this._insertKeyframe(row.label, fi);
|
||
}
|
||
});
|
||
el.appendChild(kfBtn);
|
||
this._sidebarRefs[row.label] = { kfBtn, row };
|
||
el.addEventListener("click", e => {
|
||
if (e.shiftKey && this._lastClickedLayerLabel) {
|
||
// Range select: select all joint rows between last click and this one
|
||
const allJointRows = this._getLayerDefs().filter(r => r.type === "joint");
|
||
const lastIdx = allJointRows.findIndex(r => r.label === this._lastClickedLayerLabel);
|
||
const thisIdx = allJointRows.findIndex(r => r.label === row.label);
|
||
if (lastIdx !== -1 && thisIdx !== -1) {
|
||
const lo = Math.min(lastIdx, thisIdx), hi = Math.max(lastIdx, thisIdx);
|
||
for (let i = lo; i <= hi; i++) this.selectedJoints.add(allJointRows[i].label);
|
||
} else {
|
||
this.selectedJoints.add(row.label);
|
||
}
|
||
} else if (e.ctrlKey) {
|
||
// Ctrl: toggle individual without clearing others
|
||
if (this.selectedJoints.has(row.label)) this.selectedJoints.delete(row.label);
|
||
else this.selectedJoints.add(row.label);
|
||
this._lastClickedLayerLabel = row.label;
|
||
} else {
|
||
// Plain click: select only this joint
|
||
this.selectedJoints.clear();
|
||
this.selectedJoints.add(row.label);
|
||
this._lastClickedLayerLabel = row.label;
|
||
}
|
||
this.selectedJoint = { group:row.group, index:row.index, label:row.label };
|
||
this._updateJointInfo(); this._renderFrame(this.currentFrame); this._refreshTimeline();
|
||
if (this.activeTab === "graph") { this._graphAutoFitRange(); this._renderGraphEditor(); }
|
||
});
|
||
}
|
||
this.layerPanel.appendChild(el);
|
||
});
|
||
}
|
||
|
||
_frameW() {
|
||
const avail = this.trackWrapper?.clientWidth || 400;
|
||
return Math.max(4, Math.min(20, Math.floor(avail / Math.max(1, this.frameCount))));
|
||
}
|
||
|
||
// Non-linear frame ↔ pixel mapping for zone-based timeline layout.
|
||
// Pre zone (frames [0, rangeStart-1]): compressed into PRE_W = 60px
|
||
// Mid zone (frames [rangeStart, rangeEnd]): expanded into MID_W px
|
||
// Post zone (frames [rangeEnd+1, fc-1]): compressed into POST_W = 60px
|
||
// When range covers the full video, PRE=0 and POST=0 → linear layout.
|
||
|
||
_frameToX(fi) {
|
||
const W = this.trackWrapper?.clientWidth || 400;
|
||
const rs = this.frameRangeStart, re = this.frameRangeEnd, fc = this.frameCount;
|
||
const PRE = rs > 0 ? 60 : 0;
|
||
const POST = re < fc - 1 ? 60 : 0;
|
||
const MID = W - PRE - POST;
|
||
if (fi < rs) {
|
||
return PRE * (fi + 0.5) / Math.max(1, rs);
|
||
} else if (fi <= re) {
|
||
return PRE + MID * (fi - rs + 0.5) / Math.max(1, re - rs + 1);
|
||
} else {
|
||
return PRE + MID + POST * (fi - re - 1 + 0.5) / Math.max(1, fc - 1 - re);
|
||
}
|
||
}
|
||
|
||
_framePxAt(fi) {
|
||
const W = this.trackWrapper?.clientWidth || 400;
|
||
const rs = this.frameRangeStart, re = this.frameRangeEnd, fc = this.frameCount;
|
||
const PRE = rs > 0 ? 60 : 0;
|
||
const POST = re < fc - 1 ? 60 : 0;
|
||
const MID = W - PRE - POST;
|
||
if (fi < rs) return PRE / Math.max(1, rs);
|
||
if (fi <= re) return MID / Math.max(1, re - rs + 1);
|
||
return POST / Math.max(1, fc - 1 - re);
|
||
}
|
||
|
||
_xToFrame(x) {
|
||
const W = this.trackWrapper?.clientWidth || 400;
|
||
const rs = this.frameRangeStart, re = this.frameRangeEnd, fc = this.frameCount;
|
||
const PRE = rs > 0 ? 60 : 0;
|
||
const POST = re < fc - 1 ? 60 : 0;
|
||
const MID = W - PRE - POST;
|
||
let fi;
|
||
if (x <= PRE && PRE > 0) {
|
||
fi = Math.floor((x / PRE) * rs);
|
||
} else if (x >= W - POST && POST > 0) {
|
||
fi = re + 1 + Math.floor(((x - (W - POST)) / POST) * (fc - 1 - re));
|
||
} else {
|
||
fi = rs + Math.floor(((x - PRE) / Math.max(1, MID)) * (re - rs + 1));
|
||
}
|
||
return Math.max(0, Math.min(fc - 1, fi));
|
||
}
|
||
|
||
_renderTrack() {
|
||
if (!this.trackCanvas || this.frameCount === 0) return;
|
||
const rows = this._getLayerDefs();
|
||
const W = this.trackWrapper?.clientWidth || 400;
|
||
const H = rows.length * ROW_H;
|
||
this.trackCanvas.width = W; this.trackCanvas.height = Math.max(H, 1);
|
||
const ctx = this.trackCanvas.getContext("2d");
|
||
|
||
// Zone metrics
|
||
const rs = this.frameRangeStart, re = this.frameRangeEnd;
|
||
const PRE_W = rs > 0 ? 60 : 0;
|
||
const POST_W = re < this.frameCount - 1 ? 60 : 0;
|
||
const MID_W = W - PRE_W - POST_W;
|
||
|
||
// Pre/post zone darkening
|
||
if (PRE_W > 0) { ctx.fillStyle = "rgba(0,0,0,0.3)"; ctx.fillRect(0, 0, PRE_W, H); }
|
||
if (POST_W > 0) { ctx.fillStyle = "rgba(0,0,0,0.3)"; ctx.fillRect(PRE_W + MID_W, 0, POST_W, H); }
|
||
|
||
// Row backgrounds
|
||
rows.forEach((row, ri) => {
|
||
ctx.fillStyle = row.type==="group" ? "#171d32" : ri%2===0?"#12131e":"#0e0f19";
|
||
ctx.fillRect(0, ri*ROW_H, W, ROW_H);
|
||
});
|
||
|
||
// Re-apply zone darkening on top of row backgrounds
|
||
if (PRE_W > 0) { ctx.fillStyle = "rgba(0,0,0,0.25)"; ctx.fillRect(0, 0, PRE_W, H); }
|
||
if (POST_W > 0) { ctx.fillStyle = "rgba(0,0,0,0.25)"; ctx.fillRect(PRE_W + MID_W, 0, POST_W, H); }
|
||
|
||
// Frame grid — major at /10, minor at /5; skip dense frames in compressed zones
|
||
ctx.lineWidth = 1;
|
||
for (let fi=0; fi<this.frameCount; fi++) {
|
||
if (this._framePxAt(fi) < 3 && fi % 10 !== 0) continue;
|
||
const x = this._frameToX(fi);
|
||
if (fi % 10 === 0) {
|
||
ctx.strokeStyle = "#252a44";
|
||
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke();
|
||
ctx.fillStyle = "#5a6a88"; ctx.font = "9px 'JetBrains Mono',monospace";
|
||
ctx.fillText(String(fi), x+2, 10);
|
||
} else if (fi % 5 === 0) {
|
||
ctx.strokeStyle = "#1a1e30";
|
||
ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Range IN / OUT boundary markers
|
||
if (PRE_W > 0) {
|
||
ctx.strokeStyle = "#5bc4ff"; ctx.lineWidth = 1.5; ctx.setLineDash([4,3]);
|
||
ctx.beginPath(); ctx.moveTo(PRE_W, 0); ctx.lineTo(PRE_W, H); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "#5bc4ff"; ctx.font = "bold 9px 'JetBrains Mono',monospace";
|
||
ctx.fillText("IN", PRE_W + 2, 10);
|
||
}
|
||
if (POST_W > 0) {
|
||
const xOut = PRE_W + MID_W;
|
||
ctx.strokeStyle = "#f5a840"; ctx.lineWidth = 1.5; ctx.setLineDash([4,3]);
|
||
ctx.beginPath(); ctx.moveTo(xOut, 0); ctx.lineTo(xOut, H); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "#f5a840"; ctx.font = "bold 9px 'JetBrains Mono',monospace";
|
||
ctx.fillText("OUT", xOut + 2, 10);
|
||
}
|
||
|
||
// Row dividers
|
||
ctx.strokeStyle = "#1a1e2e"; ctx.lineWidth = 1;
|
||
rows.forEach((_, ri) => {
|
||
ctx.beginPath(); ctx.moveTo(0,ri*ROW_H-0.5); ctx.lineTo(W,ri*ROW_H-0.5); ctx.stroke();
|
||
});
|
||
|
||
// Current frame highlight
|
||
const cfx = this._frameToX(this.currentFrame);
|
||
const cfw = this._framePxAt(this.currentFrame);
|
||
ctx.fillStyle = "rgba(91,196,255,0.1)"; ctx.fillRect(cfx - cfw/2, 0, cfw, H);
|
||
ctx.strokeStyle = "#5bc4ff"; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(cfx, 0); ctx.lineTo(cfx, H); ctx.stroke();
|
||
|
||
// Tween lines (colored by interpolation mode per segment)
|
||
rows.forEach((row, ri) => {
|
||
if (row.type !== "joint" && row.type !== "camscalar") return;
|
||
if (this.hiddenGroups.has(row.group) || this.hiddenLayers.has(row.label)) return;
|
||
const kfs = this._getKeyframesForJoint(row.label);
|
||
if (kfs.length < 2) return;
|
||
const y = ri*ROW_H + ROW_H/2;
|
||
for (let i=0; i<kfs.length-1; i++) {
|
||
const x0 = this._frameToX(kfs[i]), x1 = this._frameToX(kfs[i+1]);
|
||
const segMode = this.tweens[kfs[i]]?.[row.label] ?? this.interpolationMode;
|
||
const segColor = segMode === "catmull_rom" ? "#40e8a0" :
|
||
segMode === "constant" ? "#556070" :
|
||
(segMode.startsWith("back")||segMode.startsWith("elastic")||segMode==="bounce_out") ? "#f5a040" :
|
||
row.color + "99";
|
||
ctx.strokeStyle = segColor; ctx.lineWidth = 2;
|
||
ctx.setLineDash([4,3]);
|
||
ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
});
|
||
|
||
// Keyframe icons — shape encodes interpolation type (AE-style)
|
||
// diamond=linear, circle=smooth(catmull_rom/cubic/etc), square=hold/constant, ease=hourglass
|
||
const KFSZ = 9, KFSZ_SEL = 12, KFSZ_CUR = 10;
|
||
|
||
const kfShape = m => {
|
||
if (!m || m === 'linear') return 'diamond';
|
||
if (m === 'constant') return 'square';
|
||
if (m === 'ease') return 'ease';
|
||
return 'circle'; // catmull_rom, ease_in, ease_out, cubic_*, back_*, elastic_out, etc.
|
||
};
|
||
|
||
const drawKfPath = (shape, sz) => {
|
||
ctx.beginPath();
|
||
const r = sz * 0.5;
|
||
if (shape === 'square') {
|
||
ctx.rect(-r * 0.88, -r * 0.88, r * 1.76, r * 1.76);
|
||
} else if (shape === 'circle') {
|
||
ctx.arc(0, 0, r * 1.08, 0, Math.PI * 2);
|
||
} else if (shape === 'ease') {
|
||
// Hourglass: diamond pinched inward at sides
|
||
const w = r * 0.52;
|
||
ctx.moveTo(0, -r);
|
||
ctx.bezierCurveTo( w, -r * 0.22, w, r * 0.22, 0, r);
|
||
ctx.bezierCurveTo(-w, r * 0.22, -w, -r * 0.22, 0, -r);
|
||
ctx.closePath();
|
||
} else {
|
||
// Diamond
|
||
ctx.moveTo(0, -r); ctx.lineTo(r, 0); ctx.lineTo(0, r); ctx.lineTo(-r, 0);
|
||
ctx.closePath();
|
||
}
|
||
};
|
||
|
||
rows.forEach((row, ri) => {
|
||
if (row.type !== "joint" && row.type !== "camscalar") return;
|
||
if (this.hiddenGroups.has(row.group) || this.hiddenLayers.has(row.label)) return;
|
||
const kfs = this._getKeyframesForJoint(row.label);
|
||
const y = ri * ROW_H + ROW_H / 2;
|
||
|
||
kfs.forEach((fi, kfIdx) => {
|
||
const key = `${fi}::${row.label}`;
|
||
const isMultiSel = this.selKfs.has(key);
|
||
const isJointSel = this.selectedJoint?.label === row.label;
|
||
const isCurAndSel = fi === this.currentFrame && isJointSel;
|
||
const sz = isMultiSel ? KFSZ_SEL : isCurAndSel ? KFSZ_CUR : KFSZ;
|
||
const x = this._frameToX(fi);
|
||
|
||
// Outgoing = tween leaving this KF; incoming = tween arriving at this KF
|
||
const prevFi = kfIdx > 0 ? kfs[kfIdx - 1] : null;
|
||
const nextFi = kfIdx < kfs.length - 1 ? kfs[kfIdx + 1] : null;
|
||
const gm = this.interpolationMode;
|
||
const outMode = nextFi !== null ? (this.tweens[fi]?.[row.label] || gm) : null;
|
||
const inMode = prevFi !== null ? (this.tweens[prevFi]?.[row.label] || gm) : null;
|
||
const outShape = outMode ? kfShape(outMode) : null;
|
||
const inShape = inMode ? kfShape(inMode) : null;
|
||
const baseShape = outShape || inShape || 'diamond';
|
||
const sameShape = !outShape || !inShape || outShape === inShape;
|
||
|
||
const baseColor = isMultiSel ? "#5bc4ff" : isCurAndSel ? "#f7c840" : (row.color || "#f7c840");
|
||
const strokeC = isMultiSel ? "#0068aa" : "#0a0c14";
|
||
const strokeW = isMultiSel ? 1.5 : 0.8;
|
||
|
||
ctx.save();
|
||
ctx.translate(x, y);
|
||
|
||
// Selection glow
|
||
if (isMultiSel) {
|
||
drawKfPath(baseShape, sz + 5);
|
||
ctx.fillStyle = "rgba(91,196,255,0.18)";
|
||
ctx.fill();
|
||
}
|
||
|
||
if (sameShape) {
|
||
// Single unified icon
|
||
drawKfPath(baseShape, sz);
|
||
ctx.fillStyle = baseColor;
|
||
ctx.strokeStyle = strokeC;
|
||
ctx.lineWidth = strokeW;
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
} else {
|
||
// Split: left half = incoming shape, right half = outgoing shape
|
||
for (const [side, shape] of [['left', inShape], ['right', outShape]]) {
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
if (side === 'left') ctx.rect(-sz * 2.5, -sz * 2, sz * 2.5, sz * 4);
|
||
else ctx.rect(0, -sz * 2, sz * 2.5, sz * 4);
|
||
ctx.clip();
|
||
drawKfPath(shape, sz);
|
||
ctx.fillStyle = baseColor;
|
||
ctx.strokeStyle = strokeC;
|
||
ctx.lineWidth = strokeW;
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
// Hairline divider between halves
|
||
ctx.strokeStyle = "#000";
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, -sz * 0.6);
|
||
ctx.lineTo(0, sz * 0.6);
|
||
ctx.stroke();
|
||
}
|
||
|
||
ctx.restore();
|
||
});
|
||
});
|
||
|
||
// Sub-rows: joint_detail (position/conf) and wrist_rotation
|
||
rows.forEach((row, ri) => {
|
||
const ry2 = ri * ROW_H + ROW_H / 2;
|
||
if (row.type === "joint_detail") {
|
||
if (row.detail === "zoffset") return;
|
||
const detKfs = this._getKeyframesForDetailChannel(row.label, row.detail);
|
||
if (detKfs.length === 0) return;
|
||
const detColor = row.detail === "z" ? "#557799" : row.detail === "xy" ? "#6688bb" : "#558855";
|
||
for (const fi of detKfs) {
|
||
const key3 = `${fi}::${row.label}::${row.detail}`;
|
||
const isSel = this.selKfs.has(key3);
|
||
const x2 = this._frameToX(fi);
|
||
const sz = isSel ? KFSZ_SEL : KFSZ;
|
||
ctx.fillStyle = isSel ? "#ffffff" : detColor;
|
||
ctx.strokeStyle = "#111"; ctx.lineWidth = isSel ? 1.5 : 0.8;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x2, ry2-sz/2); ctx.lineTo(x2+sz/2, ry2);
|
||
ctx.lineTo(x2, ry2+sz/2); ctx.lineTo(x2-sz/2, ry2);
|
||
ctx.closePath(); ctx.fill(); ctx.stroke();
|
||
}
|
||
} else if (row.type === "wrist_rotation") {
|
||
// Draw colored diamonds per rotation channel, with selection highlight
|
||
for (const ch of ["rx","ry","rz"]) {
|
||
const chColor = ch==="rx"?"#ff8844":ch==="ry"?"#44bbff":"#ffdd44";
|
||
const kfs = this._getKeyframesForChannel(row.label, ch);
|
||
for (const fi of kfs) {
|
||
const key3 = `${fi}::${row.label}::${ch}`;
|
||
const isSel = this.selKfs.has(key3);
|
||
const x2 = this._frameToX(fi);
|
||
const sz = isSel ? KFSZ_SEL : KFSZ;
|
||
ctx.fillStyle = isSel ? "#ffffff" : chColor;
|
||
ctx.strokeStyle = "#111"; ctx.lineWidth = isSel ? 1.5 : 0.8;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x2, ry2-sz/2); ctx.lineTo(x2+sz/2, ry2);
|
||
ctx.lineTo(x2, ry2+sz/2); ctx.lineTo(x2-sz/2, ry2);
|
||
ctx.closePath(); ctx.fill(); ctx.stroke();
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Rubber-band selection rect
|
||
if (this._trackDrag?.mode === "rubber_band" && this._trackDrag.moved) {
|
||
const {startX,startY,curX,curY} = this._trackDrag;
|
||
const rx=Math.min(startX,curX), ry=Math.min(startY,curY);
|
||
const rw=Math.abs(curX-startX), rh=Math.abs(curY-startY);
|
||
ctx.fillStyle = "rgba(91,196,255,0.1)"; ctx.fillRect(rx,ry,rw,rh);
|
||
ctx.strokeStyle = "#5bc4ff"; ctx.lineWidth = 1; ctx.setLineDash([3,2]);
|
||
ctx.strokeRect(rx,ry,rw,rh); ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
_trackCanvasPos(e) {
|
||
// getBoundingClientRect already reflects scroll (canvas shifts up/left on screen
|
||
// as the wrapper scrolls), so no scrollTop/scrollLeft addition needed.
|
||
const rect = this.trackCanvas.getBoundingClientRect();
|
||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
}
|
||
|
||
_onTrackMouseDown(e) {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
const {x, y} = this._trackCanvasPos(e);
|
||
const fi = this._xToFrame(x);
|
||
const ri = Math.floor(y/ROW_H);
|
||
const row = this._getLayerDefs()[ri];
|
||
|
||
// Update joint selection immediately so viewport updates
|
||
if (row?.type === "joint") {
|
||
this.selectedJoint = { group: row.group, index: row.index, label: row.label };
|
||
if (!e.shiftKey) this.selectedJoints.clear();
|
||
this.selectedJoints.add(row.label);
|
||
this._updateJointInfo(); this._renderFrame(this.currentFrame);
|
||
if (this.activeTab === "graph") { this._graphAutoFitRange(); this._renderGraphEditor(); }
|
||
}
|
||
|
||
this._seekFrame(fi);
|
||
|
||
// Detect if we're clicking on an existing diamond — use pixel-radius hit detection
|
||
// so clicks near (not exactly on) a keyframe icon still register.
|
||
const rowIsHidden = row?.type === "joint" && (this.hiddenGroups.has(row.group) || this.hiddenLayers.has(row.label));
|
||
let hitFi = null;
|
||
let hitCh = null;
|
||
if (row?.type === "joint" && !rowIsHidden) {
|
||
const KF_HIT_PX = 10;
|
||
let minDist = KF_HIT_PX;
|
||
for (const kf of this._getKeyframesForJoint(row.label)) {
|
||
const d = Math.abs(this._frameToX(kf) - x);
|
||
if (d < minDist) { minDist = d; hitFi = kf; }
|
||
}
|
||
} else if (row?.type === "wrist_rotation") {
|
||
const KF_HIT_PX = 10;
|
||
let minDist = KF_HIT_PX;
|
||
for (const ch of ["rx","ry","rz"]) {
|
||
for (const kf of this._getKeyframesForChannel(row.label, ch)) {
|
||
const d = Math.abs(this._frameToX(kf) - x);
|
||
if (d < minDist) { minDist = d; hitFi = kf; hitCh = ch; }
|
||
}
|
||
}
|
||
} else if (row?.type === "camscalar") {
|
||
const KF_HIT_PX = 10;
|
||
let minDist = KF_HIT_PX;
|
||
for (const kf of this._getKeyframesForJoint(row.label)) {
|
||
const d = Math.abs(this._frameToX(kf) - x);
|
||
if (d < minDist) { minDist = d; hitFi = kf; }
|
||
}
|
||
} else if (row?.type === "joint_detail" && row.detail !== "zoffset") {
|
||
const KF_HIT_PX = 10;
|
||
let minDist = KF_HIT_PX;
|
||
for (const kf of this._getKeyframesForDetailChannel(row.label, row.detail)) {
|
||
const d = Math.abs(this._frameToX(kf) - x);
|
||
if (d < minDist) { minDist = d; hitFi = kf; hitCh = row.detail; }
|
||
}
|
||
}
|
||
const key = hitFi !== null
|
||
? (hitCh ? `${hitFi}::${row.label}::${hitCh}` : `${hitFi}::${row.label}`)
|
||
: null;
|
||
const hasDiamond = key !== null;
|
||
const onSelectedDiamond = hasDiamond && this.selKfs.has(key) && !e.shiftKey;
|
||
const shiftOnDiamond = hasDiamond && this.selKfs.has(key) && e.shiftKey;
|
||
|
||
// Build drag state object
|
||
const trackMode = onSelectedDiamond ? "kf_move" : shiftOnDiamond ? "kf_duplicate" : "rubber_band";
|
||
this._trackDrag = {
|
||
startX: x, startY: y, curX: x, curY: y,
|
||
fi, row, key, hasDiamond,
|
||
shiftKey: e.shiftKey,
|
||
moved: false, // true once threshold crossed
|
||
mode: trackMode,
|
||
origOverrides: (onSelectedDiamond || shiftOnDiamond) ? JSON.parse(JSON.stringify(this.overrides)) : null,
|
||
startFi: hitFi !== null ? hitFi : fi,
|
||
};
|
||
|
||
this._renderTrack();
|
||
|
||
// Attach document-level listeners so drag continues outside the canvas
|
||
this._trackDocMove = ev => this._onTrackMouseMove(ev);
|
||
this._trackDocUp = ev => this._onTrackMouseUp(ev);
|
||
document.addEventListener("mousemove", this._trackDocMove);
|
||
document.addEventListener("mouseup", this._trackDocUp);
|
||
}
|
||
|
||
_onTrackMouseMove(e) {
|
||
if (!this._trackDrag) return;
|
||
const {x, y} = this._trackCanvasPos(e);
|
||
const drag = this._trackDrag;
|
||
drag.curX = x; drag.curY = y;
|
||
|
||
// Threshold to confirm drag
|
||
if (!drag.moved) {
|
||
const dx = Math.abs(x - drag.startX), dy = Math.abs(y - drag.startY);
|
||
if (dx <= 3 && dy <= 3) { this._renderTrack(); return; }
|
||
drag.moved = true;
|
||
if (drag.mode === "kf_move") this._pushUndo(); // push once on first move
|
||
}
|
||
|
||
if (drag.mode === "kf_move" || drag.mode === "kf_duplicate") {
|
||
const isDup = drag.mode === "kf_duplicate";
|
||
const deltaFi = this._xToFrame(x) - drag.startFi;
|
||
if (deltaFi !== this._lastKfMoveDelta) {
|
||
this._lastKfMoveDelta = deltaFi;
|
||
// Restore snapshot (originals always intact in origOverrides)
|
||
this.overrides = JSON.parse(JSON.stringify(drag.origOverrides));
|
||
this.tweens = {};
|
||
const toAdd = [];
|
||
for (const key of this.selKfs) {
|
||
const parts = key.split("::");
|
||
const origFi = parseInt(parts[0]);
|
||
const newFi = Math.max(0, Math.min(this.frameCount-1, origFi + deltaFi));
|
||
if (parts.length === 3 && ["xy","conf","z"].includes(parts[2])) {
|
||
// joint_detail channel keyframe — stored as indices in overrides[fi][label]
|
||
const label = parts[1], detail = parts[2];
|
||
const ov = this.overrides[origFi]?.[label];
|
||
if (ov) {
|
||
const idxs = detail === "xy" ? [0,1] : detail === "conf" ? [2] : [3];
|
||
toAdd.push({ origFi, newFi, label, detail, idxs, vals: idxs.map(i => ov[i]), isJointDetail: true });
|
||
}
|
||
} else if (parts.length === 3) {
|
||
// wrist_rotation channel keyframe: "fi::label::ch"
|
||
const chKey = `${parts[1]}::${parts[2]}`;
|
||
if (this.overrides[origFi]?.[chKey] !== undefined)
|
||
toAdd.push({ origFi, newFi, chKey, data: this.overrides[origFi][chKey], isChannel: true });
|
||
} else {
|
||
const label = parts[1];
|
||
if (this.overrides[origFi]?.[label] !== undefined)
|
||
toAdd.push({ origFi, newFi, label, data: [...this.overrides[origFi][label]], isChannel: false });
|
||
}
|
||
}
|
||
if (!isDup) {
|
||
// Move: remove from original positions
|
||
for (const item of toAdd) {
|
||
if (!this.overrides[item.origFi]) continue;
|
||
if (item.isJointDetail) {
|
||
const ov = this.overrides[item.origFi][item.label];
|
||
if (ov) { for (const i of item.idxs) ov[i] = null; }
|
||
if (!ov || ov.every(v => v == null)) {
|
||
delete this.overrides[item.origFi][item.label];
|
||
if (Object.keys(this.overrides[item.origFi]).length === 0) delete this.overrides[item.origFi];
|
||
}
|
||
} else if (item.isChannel) {
|
||
delete this.overrides[item.origFi][item.chKey];
|
||
if (Object.keys(this.overrides[item.origFi]).length === 0) delete this.overrides[item.origFi];
|
||
} else {
|
||
delete this.overrides[item.origFi][item.label];
|
||
if (Object.keys(this.overrides[item.origFi]).length === 0) delete this.overrides[item.origFi];
|
||
}
|
||
}
|
||
}
|
||
// Write at new positions
|
||
for (const item of toAdd) {
|
||
if (!this.overrides[item.newFi]) this.overrides[item.newFi] = {};
|
||
if (item.isJointDetail) {
|
||
if (!this.overrides[item.newFi][item.label]) this.overrides[item.newFi][item.label] = [null,null,null,null];
|
||
for (let k = 0; k < item.idxs.length; k++) this.overrides[item.newFi][item.label][item.idxs[k]] = item.vals[k];
|
||
} else if (item.isChannel) {
|
||
this.overrides[item.newFi][item.chKey] = item.data;
|
||
} else {
|
||
this.overrides[item.newFi][item.label] = item.data;
|
||
}
|
||
}
|
||
// selKfs tracks the moved/duplicated positions
|
||
const newSel = new Set();
|
||
for (const key of this.selKfs) {
|
||
const parts = key.split("::");
|
||
const origFi = parseInt(parts[0]);
|
||
const newFi = Math.max(0, Math.min(this.frameCount-1, origFi + deltaFi));
|
||
if (parts.length === 3) newSel.add(`${newFi}::${parts[1]}::${parts[2]}`);
|
||
else newSel.add(`${newFi}::${parts[1]}`);
|
||
}
|
||
this.selKfs = newSel;
|
||
this._renderTrack(); this._renderFrame(this.currentFrame);
|
||
}
|
||
} else {
|
||
// Rubber-band selection — also seek frame under cursor
|
||
const hoverFi = this._xToFrame(x);
|
||
this._seekFrame(hoverFi);
|
||
this._renderTrack();
|
||
}
|
||
}
|
||
|
||
_onTrackMouseUp(e) {
|
||
// Remove document-level listeners first
|
||
if (this._trackDocMove) { document.removeEventListener("mousemove", this._trackDocMove); this._trackDocMove = null; }
|
||
if (this._trackDocUp) { document.removeEventListener("mouseup", this._trackDocUp); this._trackDocUp = null; }
|
||
|
||
const drag = this._trackDrag;
|
||
this._trackDrag = null;
|
||
if (!drag) return;
|
||
|
||
if (drag.mode === "kf_move" || drag.mode === "kf_duplicate") {
|
||
if (!drag.moved) {
|
||
// Pure click (no drag): toggle selection
|
||
if (drag.key) {
|
||
if (drag.shiftKey) {
|
||
if (this.selKfs.has(drag.key)) this.selKfs.delete(drag.key);
|
||
else this.selKfs.add(drag.key);
|
||
} else {
|
||
this.selKfs.clear(); this.selKfs.add(drag.key);
|
||
}
|
||
}
|
||
// Restore overrides (nothing actually changed in the data)
|
||
if (drag.origOverrides) this.overrides = drag.origOverrides;
|
||
} else {
|
||
// Drag completed: selKfs already updated in _onTrackMouseMove
|
||
this._lastKfMoveDelta = 0;
|
||
this._refreshTimeline();
|
||
}
|
||
this._renderTrack();
|
||
return;
|
||
}
|
||
|
||
// Rubber-band mode
|
||
if (!drag.moved) {
|
||
// Pure click — toggle diamond selection
|
||
const { fi, row, key, hasDiamond, shiftKey } = drag;
|
||
if ((row?.type === "joint" || row?.type === "wrist_rotation" || row?.type === "camscalar" || row?.type === "joint_detail") && key) {
|
||
if (hasDiamond) {
|
||
if (shiftKey) {
|
||
if (this.selKfs.has(key)) this.selKfs.delete(key);
|
||
else this.selKfs.add(key);
|
||
} else {
|
||
this.selKfs.clear(); this.selKfs.add(key);
|
||
}
|
||
} else {
|
||
if (!shiftKey) this.selKfs.clear();
|
||
}
|
||
} else {
|
||
if (!shiftKey) this.selKfs.clear();
|
||
}
|
||
this._renderTrack();
|
||
return;
|
||
}
|
||
|
||
// Real rubber-band — select keyframes in rect
|
||
const minX = Math.min(drag.startX, drag.curX), maxX = Math.max(drag.startX, drag.curX);
|
||
const minY = Math.min(drag.startY, drag.curY), maxY = Math.max(drag.startY, drag.curY);
|
||
const fi0 = this._xToFrame(minX);
|
||
const fi1 = this._xToFrame(maxX);
|
||
const ri0 = Math.floor(minY / ROW_H), ri1 = Math.floor(maxY / ROW_H);
|
||
const rows = this._getLayerDefs();
|
||
if (!drag.shiftKey) this.selKfs.clear();
|
||
for (let ri = ri0; ri <= ri1; ri++) {
|
||
const row = rows[ri]; if (!row) continue;
|
||
if (row.type === "joint") {
|
||
if (this.hiddenGroups.has(row.group) || this.hiddenLayers.has(row.label)) continue;
|
||
for (let fi = fi0; fi <= fi1; fi++)
|
||
if (this.overrides[fi]?.[row.label] !== undefined)
|
||
this.selKfs.add(`${fi}::${row.label}`);
|
||
} else if (row.type === "wrist_rotation") {
|
||
for (const ch of ["rx","ry","rz"])
|
||
for (let fi = fi0; fi <= fi1; fi++)
|
||
if (this.overrides[fi]?.[`${row.label}::${ch}`] !== undefined)
|
||
this.selKfs.add(`${fi}::${row.label}::${ch}`);
|
||
} else if (row.type === "camscalar") {
|
||
for (let fi = fi0; fi <= fi1; fi++)
|
||
if (this.overrides[fi]?.[row.label] !== undefined)
|
||
this.selKfs.add(`${fi}::${row.label}`);
|
||
} else if (row.type === "joint_detail" && row.detail !== "zoffset") {
|
||
const idxMap = { xy: 0, conf: 2, z: 3 };
|
||
const idx = idxMap[row.detail];
|
||
for (let fi = fi0; fi <= fi1; fi++) {
|
||
const ov = this.overrides[fi]?.[row.label];
|
||
if (ov && ov[idx] != null) this.selKfs.add(`${fi}::${row.label}::${row.detail}`);
|
||
}
|
||
}
|
||
}
|
||
this._renderTrack();
|
||
}
|
||
|
||
_onTrackRightClick(e) {
|
||
const rect = this.trackCanvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const fi = this._xToFrame(x);
|
||
const ri = Math.floor(y/ROW_H);
|
||
const row = this._getLayerDefs()[ri];
|
||
if (!row || (row.type !== "joint" && row.type !== "wrist_rotation" && row.type !== "camscalar")) return;
|
||
if (row.type === "wrist_rotation") {
|
||
// Right-click on rotation diamond → delete that channel keyframe
|
||
const KF_HIT_PX = 10;
|
||
let hitFi = null, hitCh = null, minDist = KF_HIT_PX;
|
||
for (const ch of ["rx","ry","rz"]) {
|
||
for (const kf of this._getKeyframesForChannel(row.label, ch)) {
|
||
const d = Math.abs(this._frameToX(kf) - x);
|
||
if (d < minDist) { minDist = d; hitFi = kf; hitCh = ch; }
|
||
}
|
||
}
|
||
if (hitFi !== null && hitCh !== null) {
|
||
this._pushUndo();
|
||
const chKey = `${row.label}::${hitCh}`;
|
||
if (this.overrides[hitFi]) {
|
||
delete this.overrides[hitFi][chKey];
|
||
if (Object.keys(this.overrides[hitFi]).length === 0) delete this.overrides[hitFi];
|
||
}
|
||
this._refreshTimeline(); this._renderFrame(hitFi);
|
||
}
|
||
return;
|
||
}
|
||
const kfs = this._getKeyframesForJoint(row.label);
|
||
// Radius-based hit detection for right-click delete
|
||
const KF_HIT_PX = 10;
|
||
let hitFi = null, minDist = KF_HIT_PX;
|
||
for (const kf of kfs) {
|
||
const d = Math.abs(this._frameToX(kf) - x);
|
||
if (d < minDist) { minDist = d; hitFi = kf; }
|
||
}
|
||
if (hitFi !== null) {
|
||
// Right-click on a diamond → delete keyframe
|
||
this._deleteKeyframe(row.label, hitFi);
|
||
} else if (kfs.length >= 2) {
|
||
// Right-click between keyframes → per-segment easing popup
|
||
let leftFi = -1;
|
||
for (let i=kfs.length-1; i>=0; i--) if (kfs[i] < fi) { leftFi=kfs[i]; break; }
|
||
if (leftFi !== -1) {
|
||
const rightFi = kfs[kfs.findIndex(f=>f>fi)];
|
||
if (rightFi !== undefined)
|
||
this._showSegmentPopup(row.label, leftFi, rightFi, e.clientX, e.clientY);
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Undo / Redo
|
||
// -----------------------------------------------------------------------
|
||
_snapshot() {
|
||
return {
|
||
overrides: JSON.parse(JSON.stringify(this.overrides)),
|
||
tweens: JSON.parse(JSON.stringify(this.tweens)),
|
||
};
|
||
}
|
||
_pushUndo() {
|
||
this._undoStack.push(this._snapshot());
|
||
if (this._undoStack.length > 30) this._undoStack.shift();
|
||
this._redoStack = [];
|
||
this._dragPreState = null; // any explicit push clears lazy state
|
||
}
|
||
/** Call at drag start — captures state for lazy push on first actual change. */
|
||
_armUndo() {
|
||
this._dragPreState = this._snapshot();
|
||
}
|
||
/** Call before modifying overrides during a drag. */
|
||
_lazyPushUndo() {
|
||
if (this._dragPreState === null) return;
|
||
this._undoStack.push(this._dragPreState);
|
||
if (this._undoStack.length > 30) this._undoStack.shift();
|
||
this._redoStack = [];
|
||
this._dragPreState = null;
|
||
}
|
||
_undo() {
|
||
if (!this._undoStack.length) return;
|
||
this._redoStack.push(this._snapshot());
|
||
const s = this._undoStack.pop();
|
||
this.overrides = s.overrides; this.tweens = s.tweens;
|
||
this._renderFrame(this.currentFrame); this._refreshTimeline();
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
}
|
||
_redo() {
|
||
if (!this._redoStack.length) return;
|
||
this._undoStack.push(this._snapshot());
|
||
const s = this._redoStack.pop();
|
||
this.overrides = s.overrides; this.tweens = s.tweens;
|
||
this._renderFrame(this.currentFrame); this._refreshTimeline();
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Multi-select operations
|
||
// -----------------------------------------------------------------------
|
||
_deleteSelectedKeyframes() {
|
||
if (this.selKfs.size === 0) return;
|
||
this._pushUndo();
|
||
for (const key of this.selKfs) {
|
||
const parts = key.split("::");
|
||
if (parts.length === 3 && ["xy","conf","z"].includes(parts[2])) {
|
||
// joint_detail channel: null specific indices in the override array
|
||
const fi = parseInt(parts[0]), label = parts[1], detail = parts[2];
|
||
const ov = this.overrides[fi]?.[label];
|
||
if (ov) {
|
||
if (detail === "xy") { ov[0] = null; ov[1] = null; }
|
||
else if (detail === "conf") ov[2] = null;
|
||
else ov[3] = null;
|
||
if (ov.every(v => v == null)) {
|
||
delete this.overrides[fi][label];
|
||
if (Object.keys(this.overrides[fi]).length === 0) delete this.overrides[fi];
|
||
}
|
||
}
|
||
} else if (parts.length === 3) {
|
||
// wrist_rotation channel keyframe
|
||
const fi = parseInt(parts[0]);
|
||
const chKey = `${parts[1]}::${parts[2]}`;
|
||
if (this.overrides[fi]) {
|
||
delete this.overrides[fi][chKey];
|
||
if (Object.keys(this.overrides[fi]).length === 0) delete this.overrides[fi];
|
||
}
|
||
} else {
|
||
this._deleteKeyframeRaw(parts[1], parseInt(parts[0]));
|
||
}
|
||
}
|
||
this.selKfs.clear();
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
}
|
||
|
||
/** Delete all keyframes strictly before the current frame (for selected joints or all joints). */
|
||
_trimKeyframesBefore() {
|
||
this._trimKeyframes(fi => fi < this.currentFrame);
|
||
}
|
||
|
||
/** Delete all keyframes strictly after the current frame (for selected joints or all joints). */
|
||
_trimKeyframesAfter() {
|
||
this._trimKeyframes(fi => fi > this.currentFrame);
|
||
}
|
||
|
||
_trimKeyframes(predicate) {
|
||
// If joint_detail channel KFs are selected, trim only those channels
|
||
if (this.selKfs.size > 0) {
|
||
const detailKeys = [...this.selKfs].filter(k => {
|
||
const p = k.split("::"); return p.length === 3 && ["xy","conf","z"].includes(p[2]);
|
||
});
|
||
if (detailKeys.length > 0) {
|
||
const targets = {};
|
||
for (const key of detailKeys) {
|
||
const [, label, detail] = key.split("::");
|
||
targets[`${label}::${detail}`] = { label, detail };
|
||
}
|
||
const idxFor = d => d === "xy" ? [0,1] : d === "conf" ? [2] : [3];
|
||
let hasTargets = false;
|
||
outer: for (const fi of Object.keys(this.overrides).map(Number)) {
|
||
if (!predicate(fi)) continue;
|
||
for (const { label, detail } of Object.values(targets)) {
|
||
const ov = this.overrides[fi]?.[label]; if (!ov) continue;
|
||
if (idxFor(detail).some(i => ov[i] != null)) { hasTargets = true; break outer; }
|
||
}
|
||
}
|
||
if (!hasTargets) return;
|
||
this._pushUndo();
|
||
for (const fi of Object.keys(this.overrides).map(Number)) {
|
||
if (!predicate(fi)) continue;
|
||
for (const { label, detail } of Object.values(targets)) {
|
||
const ov = this.overrides[fi]?.[label]; if (!ov) continue;
|
||
for (const i of idxFor(detail)) ov[i] = null;
|
||
if (ov.every(v => v == null)) {
|
||
delete this.overrides[fi][label];
|
||
if (Object.keys(this.overrides[fi]).length === 0) delete this.overrides[fi];
|
||
}
|
||
}
|
||
}
|
||
this.selKfs.clear();
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
return;
|
||
}
|
||
}
|
||
const targets = new Set(
|
||
this.selectedJoints.size > 0
|
||
? [...this.selectedJoints]
|
||
: this._getLayerDefs().filter(r => r.type === "joint" || r.type === "camscalar").map(r => r.label)
|
||
);
|
||
// Check if there's anything to remove before touching the stack
|
||
let hasTargets = false;
|
||
for (const fi of Object.keys(this.overrides).map(Number)) {
|
||
if (!predicate(fi)) continue;
|
||
if (Object.keys(this.overrides[fi] || {}).some(lbl => targets.has(lbl))) { hasTargets = true; break; }
|
||
}
|
||
if (!hasTargets) return;
|
||
this._pushUndo(); // capture state BEFORE deletion
|
||
for (const fi of Object.keys(this.overrides).map(Number)) {
|
||
if (!predicate(fi)) continue;
|
||
for (const label of Object.keys(this.overrides[fi] || {})) {
|
||
if (!targets.has(label)) continue;
|
||
if (this.lockedLayers.has(label)) continue; // respect layer lock
|
||
delete this.overrides[fi][label];
|
||
if (this.tweens[fi]) delete this.tweens[fi][label];
|
||
}
|
||
if (this.overrides[fi] && Object.keys(this.overrides[fi]).length === 0) delete this.overrides[fi];
|
||
if (this.tweens[fi] && Object.keys(this.tweens[fi]).length === 0) delete this.tweens[fi];
|
||
}
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
}
|
||
|
||
|
||
_toggleAutoKeyframe() {
|
||
this.autoKeyframe = !this.autoKeyframe;
|
||
// Discard any temp keys when turning OFF
|
||
if (!this.autoKeyframe) this._tempKeys.clear();
|
||
this._updateAutoKfBtn();
|
||
}
|
||
|
||
_updateAutoKfBtn() {
|
||
const on = this.autoKeyframe;
|
||
const style = { background: on ? "#6a2a1a" : "#3a2a1a", borderColor: on ? "#dd5533" : "#444",
|
||
color: on ? "#ffaa88" : "#aaa", fontWeight: on ? "bold" : "normal" };
|
||
for (const btn of [this.autoKfBtn, this.graphAutoKfBtn]) {
|
||
if (!btn) continue;
|
||
Object.assign(btn.style, style);
|
||
btn.textContent = on ? "⬤ Auto Key ON" : "⬤ Auto Key OFF";
|
||
}
|
||
}
|
||
|
||
_copySelectedKeyframes() {
|
||
if (this.selKfs.size === 0 && this.selectedJoint) {
|
||
// If nothing selected, copy all keyframes of selected joint
|
||
const { label } = this.selectedJoint;
|
||
for (const fi of this._getKeyframesForJoint(label))
|
||
this.selKfs.add(`${fi}::${label}`);
|
||
}
|
||
if (this.selKfs.size === 0) return;
|
||
const anchorFi = this.currentFrame;
|
||
this._kfClipboard = { anchorFi, entries: [] };
|
||
for (const key of this.selKfs) {
|
||
const [fStr, label] = key.split("::");
|
||
const fi = parseInt(fStr);
|
||
const data = this.overrides[fi]?.[label];
|
||
if (data !== undefined)
|
||
this._kfClipboard.entries.push({ label, fi_offset: fi - anchorFi, data: [...data] });
|
||
}
|
||
}
|
||
|
||
_pasteKeyframes() {
|
||
if (!this._kfClipboard || this._kfClipboard.entries.length === 0) return;
|
||
this._pushUndo();
|
||
const pasteAtFi = this.currentFrame;
|
||
this.selKfs.clear();
|
||
for (const { label, fi_offset, data } of this._kfClipboard.entries) {
|
||
const newFi = Math.max(0, Math.min(this.frameCount-1, pasteAtFi + fi_offset));
|
||
if (!this.overrides[newFi]) this.overrides[newFi] = {};
|
||
this.overrides[newFi][label] = [...data];
|
||
this.selKfs.add(`${newFi}::${label}`);
|
||
}
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
}
|
||
|
||
_selectAllKeyframes() {
|
||
// Select all visible keyframes of the currently selected joint (or all joints if none)
|
||
const targetLabel = this.selectedJoint?.label ?? null;
|
||
for (const [fi, overrideObj] of Object.entries(this.overrides)) {
|
||
for (const label of Object.keys(overrideObj)) {
|
||
if (targetLabel && label !== targetLabel) continue;
|
||
const grp = _splitLabel(label).group;
|
||
if (this.hiddenGroups.has(grp) || this.hiddenLayers.has(label)) continue;
|
||
this.selKfs.add(`${fi}::${label}`);
|
||
}
|
||
}
|
||
this._renderTrack();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Per-segment easing popup
|
||
// -----------------------------------------------------------------------
|
||
_showSegmentPopup(label, fi_left, fi_right, screenX, screenY) {
|
||
this._hideSegmentPopup();
|
||
const popup = document.createElement("div");
|
||
Object.assign(popup.style, {
|
||
position: "fixed", zIndex: "20000",
|
||
left: `${Math.min(screenX, window.innerWidth-220)}px`,
|
||
top: `${Math.min(screenY, window.innerHeight-260)}px`,
|
||
background: "#1a1a2a", border: "1px solid #445",
|
||
borderRadius: "6px", padding: "8px", width: "210px",
|
||
boxShadow: "0 4px 20px rgba(0,0,0,0.7)",
|
||
});
|
||
|
||
const title = document.createElement("div");
|
||
title.style.cssText = "font-size:11px;color:#88aacc;margin-bottom:6px;";
|
||
title.textContent = `Segment: frame ${fi_left} → ${fi_right} · ${label}`;
|
||
popup.appendChild(title);
|
||
|
||
const curMode = this.tweens[fi_left]?.[label] ?? null;
|
||
const grid = document.createElement("div");
|
||
Object.assign(grid.style, { display:"grid", gridTemplateColumns:"repeat(3,1fr)", gap:"3px" });
|
||
|
||
for (const [presetLabel, presetVal] of SEGMENT_PRESETS) {
|
||
const btn = document.createElement("button");
|
||
btn.textContent = presetLabel;
|
||
const isActive = presetVal === curMode || (presetVal === null && curMode === null);
|
||
Object.assign(btn.style, {
|
||
background: isActive ? "#1e5a3a" : "#2a2a3a",
|
||
border: `1px solid ${isActive?"#3aaa7a":"#444"}`,
|
||
color: isActive ? "#aaffcc" : "#ccc",
|
||
borderRadius: "3px", cursor: "pointer",
|
||
fontSize: "10px", padding: "3px 2px",
|
||
});
|
||
btn.addEventListener("click", () => {
|
||
this._pushUndo();
|
||
if (presetVal === null) {
|
||
// Remove per-segment override → falls back to global default
|
||
if (this.tweens[fi_left]) { delete this.tweens[fi_left][label]; }
|
||
} else {
|
||
if (!this.tweens[fi_left]) this.tweens[fi_left] = {};
|
||
this.tweens[fi_left][label] = presetVal;
|
||
}
|
||
this._hideSegmentPopup();
|
||
this._updateInterpBtns();
|
||
this._renderFrame(this.currentFrame);
|
||
this._renderTrack();
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
});
|
||
grid.appendChild(btn);
|
||
}
|
||
popup.appendChild(grid);
|
||
|
||
const closeBtn = document.createElement("button");
|
||
closeBtn.textContent = "✕";
|
||
Object.assign(closeBtn.style, {
|
||
position:"absolute", top:"4px", right:"6px", background:"none",
|
||
border:"none", color:"#888", cursor:"pointer", fontSize:"13px",
|
||
});
|
||
closeBtn.addEventListener("click", () => this._hideSegmentPopup());
|
||
popup.appendChild(closeBtn);
|
||
|
||
document.body.appendChild(popup);
|
||
this._segPopup = popup;
|
||
|
||
// Close on outside click
|
||
const onOutside = (e) => {
|
||
if (!popup.contains(e.target)) { this._hideSegmentPopup(); document.removeEventListener("mousedown", onOutside); }
|
||
};
|
||
setTimeout(() => document.addEventListener("mousedown", onOutside), 0);
|
||
}
|
||
|
||
_hideSegmentPopup() {
|
||
if (this._segPopup) { document.body.removeChild(this._segPopup); this._segPopup = null; }
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Graph editor
|
||
// -----------------------------------------------------------------------
|
||
_graphFitView() {
|
||
// Fall back to all keyframed joints if nothing explicitly selected
|
||
let labels = this._graphLabels();
|
||
if (labels.length === 0) {
|
||
labels = this._getLayerDefs()
|
||
.filter(r => (r.type === "joint" || r.type === "camscalar") && this._getKeyframesForJoint(r.label).length > 0)
|
||
.map(r => r.label);
|
||
}
|
||
if (labels.length === 0) return;
|
||
|
||
let vMin=Infinity, vMax=-Infinity, fMin=Infinity, fMax=-Infinity;
|
||
for (const lbl of labels) {
|
||
const kfs = this._getKeyframesForJoint(lbl);
|
||
if (kfs.length === 0) continue;
|
||
fMin = Math.min(fMin, kfs[0]);
|
||
fMax = Math.max(fMax, kfs[kfs.length - 1]);
|
||
const isCam = this._isCamScalarLabel(lbl);
|
||
// Sample the interpolated curve, not just raw override positions
|
||
const step = Math.max(1, Math.floor((kfs[kfs.length - 1] - kfs[0]) / 80));
|
||
for (let fi = kfs[0]; fi <= kfs[kfs.length - 1]; fi += step) {
|
||
if (this.graphShowX || isCam) {
|
||
const v = this._getValueAtFrame(lbl, 0, fi);
|
||
if (v !== null) { vMin = Math.min(vMin, v); vMax = Math.max(vMax, v); }
|
||
}
|
||
if (this.graphShowY && !isCam) {
|
||
const v = this._getValueAtFrame(lbl, 1, fi);
|
||
if (v !== null) { vMin = Math.min(vMin, v); vMax = Math.max(vMax, v); }
|
||
}
|
||
}
|
||
}
|
||
if (!isFinite(vMin)) {
|
||
this.graphViewport = { frameStart: 0, frameEnd: this.frameCount - 1, valMin: 0, valMax: Math.max(this.poseW, this.poseH) };
|
||
return;
|
||
}
|
||
const pad = Math.max((vMax - vMin) * 0.2, 15);
|
||
this.graphViewport = {
|
||
frameStart: Math.max(0, fMin - 3),
|
||
frameEnd: Math.min(this.frameCount - 1, fMax + 3),
|
||
valMin: vMin - pad, valMax: vMax + pad,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Fit the Y axis to the actual data range for all visible joints.
|
||
* Uses percentile clamping (2nd–98th) to ignore spikes/outliers,
|
||
* then adds ±100px padding for comfortable visibility.
|
||
* The frame range (X axis) is reset to the full clip.
|
||
*/
|
||
_graphAutoFitRange() {
|
||
let labels = this._graphLabels();
|
||
if (labels.length === 0) {
|
||
labels = this._getLayerDefs()
|
||
.filter(r => (r.type === "joint" || r.type === "camscalar") && this._getKeyframesForJoint(r.label).length > 0)
|
||
.map(r => r.label);
|
||
}
|
||
if (labels.length === 0) return;
|
||
|
||
// Collect sampled values across the full frame span
|
||
const allVals = [];
|
||
let fMin = Infinity, fMax = -Infinity;
|
||
for (const lbl of labels) {
|
||
const kfs = this._getKeyframesForJoint(lbl);
|
||
if (kfs.length === 0) continue;
|
||
fMin = Math.min(fMin, kfs[0]);
|
||
fMax = Math.max(fMax, kfs[kfs.length - 1]);
|
||
const isCam = this._isCamScalarLabel(lbl);
|
||
// Sample at ~3-frame intervals — enough for accurate percentile without heavy cost
|
||
const step = Math.max(1, Math.floor((kfs[kfs.length - 1] - kfs[0]) / 120));
|
||
for (let fi = kfs[0]; fi <= kfs[kfs.length - 1]; fi += step) {
|
||
if (this.graphShowX || isCam) {
|
||
const v = this._getValueAtFrame(lbl, 0, fi);
|
||
if (v !== null) allVals.push(v);
|
||
}
|
||
if (this.graphShowY && !isCam) {
|
||
const v = this._getValueAtFrame(lbl, 1, fi);
|
||
if (v !== null) allVals.push(v);
|
||
}
|
||
}
|
||
}
|
||
if (allVals.length === 0) return;
|
||
|
||
// Percentile clamping — 2nd to 98th — rejects isolated spikes
|
||
allVals.sort((a, b) => a - b);
|
||
const pct = (p) => allVals[Math.max(0, Math.min(allVals.length - 1, Math.round((allVals.length - 1) * p / 100)))];
|
||
const lo = pct(2);
|
||
const hi = pct(98);
|
||
// Proportional padding: 30% of the value span, minimum 15 units so curves have breathing room
|
||
const pad = Math.max((hi - lo) * 0.3, 15);
|
||
|
||
this.graphViewport = {
|
||
frameStart: Math.max(0, isFinite(fMin) ? fMin - 2 : 0),
|
||
frameEnd: Math.min(this.frameCount - 1, isFinite(fMax) ? fMax + 2 : this.frameCount - 1),
|
||
valMin: lo - pad,
|
||
valMax: hi + pad,
|
||
};
|
||
}
|
||
|
||
_isCamScalarLabel(label) { return label.startsWith("cam_"); }
|
||
|
||
/** Returns the list of joint/camera labels to show in the graph. */
|
||
_graphLabels() {
|
||
if (this._selectedCamLabels?.size > 0) return [...this._selectedCamLabels];
|
||
if (this.selectedJoints.size > 0) return [...this.selectedJoints];
|
||
if (this.selectedJoint) return [this.selectedJoint.label];
|
||
return [];
|
||
}
|
||
|
||
_graphTransform() {
|
||
const cw = this.graphCanvas.width || 400;
|
||
const ch = this.graphCanvas.height || 120;
|
||
const PAD = { l:45, r:10, t:12, b:24 };
|
||
const vw = cw - PAD.l - PAD.r, vh = ch - PAD.t - PAD.b;
|
||
const { frameStart, frameEnd, valMin, valMax } = this.graphViewport;
|
||
const fRange = Math.max(1, frameEnd - frameStart);
|
||
const vRange = Math.max(1, valMax - valMin);
|
||
return {
|
||
fToX: (f) => PAD.l + (f - frameStart) / fRange * vw,
|
||
vToY: (v) => PAD.t + (1 - (v - valMin) / vRange) * vh,
|
||
xToF: (x) => frameStart + (x - PAD.l) / vw * fRange,
|
||
yToV: (y) => valMin + (1 - (y - PAD.t) / vh) * vRange,
|
||
PAD, cw, ch, vw, vh,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get the interpolated or raw value for a joint at an exact frame index.
|
||
* Respects the current interpolation mode.
|
||
*/
|
||
_getValueAtFrame(label, coord, fi) {
|
||
const ov = this.overrides[fi]?.[label];
|
||
if (ov !== undefined) return ov[coord];
|
||
const interp = this._interpolateJoint(label, fi);
|
||
if (interp) return interp[coord];
|
||
// Fall back to raw frame data
|
||
const raw = this.frames[fi];
|
||
if (!raw) return null;
|
||
const { group, index: ki } = _splitLabel(label);
|
||
const pts = _grpPts(raw, group);
|
||
return pts?.[ki]?.[coord] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Apply JS-side Gaussian smoothing preview to get value for one (label, coord, fi).
|
||
* Skips anchor frames (overrides). Returns null if no data available.
|
||
*/
|
||
_getSmoothedValue(label, coord, fi) {
|
||
if (this.smoothWindow <= 0) return this._getValueAtFrame(label, coord, fi);
|
||
const r = Math.floor(this.smoothWindow / 2);
|
||
const sigma = this.smoothWindow / 3;
|
||
const anchors = new Set(Object.keys(this.overrides).map(Number));
|
||
if (anchors.has(fi)) return this._getValueAtFrame(label, coord, fi);
|
||
let wx = 0, wsum = 0;
|
||
for (let d = -r; d <= r; d++) {
|
||
const src = fi + d;
|
||
if (src < 0 || src >= this.frameCount) continue;
|
||
const w = Math.exp(-0.5 * (d / sigma) ** 2);
|
||
const v = this._getValueAtFrame(label, coord, src);
|
||
if (v === null) continue;
|
||
wx += w * v; wsum += w;
|
||
}
|
||
return wsum > 0 ? wx / wsum : null;
|
||
}
|
||
|
||
_renderGraphEditor() {
|
||
if (!this.graphCanvas || this.activeTab !== "graph") return;
|
||
|
||
// Force canvas to match its display size if not yet set
|
||
const rect = this.graphCanvas.getBoundingClientRect();
|
||
if (rect.width > 0 && (this.graphCanvas.width !== Math.round(rect.width) || this.graphCanvas.height !== Math.round(rect.height))) {
|
||
this.graphCanvas.width = Math.round(rect.width);
|
||
this.graphCanvas.height = Math.round(rect.height);
|
||
}
|
||
const cw = this.graphCanvas.width, ch = this.graphCanvas.height;
|
||
if (!cw || !ch) return;
|
||
const ctx = this.graphCanvas.getContext("2d");
|
||
ctx.clearRect(0, 0, cw, ch);
|
||
ctx.fillStyle = "#080810"; ctx.fillRect(0, 0, cw, ch);
|
||
|
||
const T = this._graphTransform();
|
||
const { fToX, vToY, PAD } = T;
|
||
|
||
// Grid — vertical frame lines
|
||
ctx.lineWidth = 1;
|
||
const fStep = this._graphFrameStep();
|
||
for (let f = Math.ceil(this.graphViewport.frameStart/fStep)*fStep; f <= this.graphViewport.frameEnd; f += fStep) {
|
||
const x = fToX(f);
|
||
ctx.strokeStyle = f % (fStep*5)===0 ? "#22283e" : "#181c2c";
|
||
ctx.beginPath(); ctx.moveTo(x, PAD.t); ctx.lineTo(x, ch-PAD.b); ctx.stroke();
|
||
ctx.fillStyle = "#5a6a88"; ctx.font = "9px 'JetBrains Mono',monospace";
|
||
ctx.fillText(String(f), x+2, ch-PAD.b+11);
|
||
}
|
||
// Grid — horizontal value lines (only in non-normalized mode; normalized mode draws its own ±1/0 grid later)
|
||
if (!this.normalizeGraph) {
|
||
const vStep = this._graphValStep();
|
||
for (let v = Math.ceil(this.graphViewport.valMin/vStep)*vStep; v <= this.graphViewport.valMax; v += vStep) {
|
||
const y = vToY(v);
|
||
ctx.strokeStyle = "#181c2c"; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(PAD.l, y); ctx.lineTo(cw-PAD.r, y); ctx.stroke();
|
||
ctx.fillStyle = "#5a6a88"; ctx.font = "9px 'JetBrains Mono',monospace";
|
||
ctx.fillText(v.toFixed(0), 2, y+3);
|
||
}
|
||
const zy = vToY(0);
|
||
if (zy >= PAD.t && zy <= ch-PAD.b) {
|
||
ctx.strokeStyle = "#2a3050"; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(PAD.l, zy); ctx.lineTo(cw-PAD.r, zy); ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Border
|
||
ctx.strokeStyle = "#252a42"; ctx.lineWidth = 1;
|
||
ctx.strokeRect(PAD.l, PAD.t, T.vw, T.vh);
|
||
|
||
// Current frame line
|
||
const cfx = fToX(this.currentFrame);
|
||
ctx.strokeStyle = "#5bc4ff"; ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([4,3]); ctx.beginPath(); ctx.moveTo(cfx,PAD.t); ctx.lineTo(cfx,ch-PAD.b); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
const labels = this._graphLabels();
|
||
if (labels.length === 0) {
|
||
ctx.fillStyle = "#3a4a68"; ctx.font = "13px 'Inter',sans-serif"; ctx.textAlign = "center";
|
||
ctx.fillText("Select a joint or camera channel in the dope sheet to see its curve", cw/2, ch/2);
|
||
ctx.textAlign = "left"; return;
|
||
}
|
||
|
||
const fStart = this.graphViewport.frameStart, fEnd = this.graphViewport.frameEnd;
|
||
|
||
// Colour palette for multi-joint: X=warm, Y=cool, cycling hue per joint
|
||
const JOINT_HUE = [0,30,60,120,180,210,270,300];
|
||
const getColors = (ji, coord) => {
|
||
const hue = JOINT_HUE[ji % JOINT_HUE.length];
|
||
const shift = coord === 0 ? 0 : 40; // X: base hue, Y: shifted
|
||
return {
|
||
raw: `hsl(${hue+shift},60%,45%)`,
|
||
bright: `hsl(${hue+shift},80%,65%)`,
|
||
};
|
||
};
|
||
|
||
// Pre-compute normalization factors: mean-centered, shared scale across all channels.
|
||
// Each channel is centered at its own mean; all channels share the same half-range
|
||
// so relative amplitudes are preserved and curves visually overlay each other.
|
||
const normFactor = {}; // key: `${label}_${coord}` → {mean, halfRange}
|
||
if (this.normalizeGraph) {
|
||
let globalHalfRange = 0;
|
||
for (let ji=0; ji<labels.length; ji++) {
|
||
const lbl = labels[ji];
|
||
const kfs = this._getKeyframesForJoint(lbl);
|
||
for (const coord of [0,1]) {
|
||
let mn=Infinity, mx=-Infinity;
|
||
for (const fi of kfs) {
|
||
const ov=this.overrides[fi]?.[lbl];
|
||
if (ov) { mn=Math.min(mn,ov[coord]); mx=Math.max(mx,ov[coord]); }
|
||
}
|
||
if (isFinite(mn)) {
|
||
normFactor[`${lbl}_${coord}`] = { mean: (mn+mx)/2, halfRange: 0 };
|
||
globalHalfRange = Math.max(globalHalfRange, (mx-mn)/2);
|
||
}
|
||
}
|
||
}
|
||
if (globalHalfRange === 0) globalHalfRange = 1;
|
||
for (const key of Object.keys(normFactor)) normFactor[key].halfRange = globalHalfRange;
|
||
}
|
||
this._lastNormFactors = normFactor; // stored so mouse hit-test can reuse
|
||
|
||
const applyNorm = (val, lbl, coord) => {
|
||
if (!this.normalizeGraph) return val;
|
||
const nf = normFactor[`${lbl}_${coord}`];
|
||
if (!nf || nf.halfRange === 0) return 0;
|
||
return (val - nf.mean) / nf.halfRange; // → 0 at mean, ±1 at ±halfRange
|
||
};
|
||
|
||
// If normalizing, Y axis maps the centered values (-1…+1 typical range)
|
||
const normalVToY = this.normalizeGraph
|
||
? (v) => PAD.t + (1 - (v + 1) / 2) * T.vh
|
||
: vToY;
|
||
|
||
for (let ji=0; ji<labels.length; ji++) {
|
||
const label = labels[ji];
|
||
const kfs = this._getKeyframesForJoint(label);
|
||
if (kfs.length < 1) continue;
|
||
const isCam = this._isCamScalarLabel(label);
|
||
|
||
for (const coord of isCam ? [0] : [0, 1]) {
|
||
const show = isCam ? true : (coord === 0 ? this.graphShowX : this.graphShowY);
|
||
if (!show) continue;
|
||
const {raw: rawColor, bright: smoothColor} = getColors(ji, coord);
|
||
|
||
// Draw curve segment-by-segment
|
||
ctx.strokeStyle = rawColor; ctx.lineWidth = labels.length > 1 ? 1.2 : 1.5;
|
||
ctx.setLineDash([]);
|
||
ctx.beginPath(); let pathStarted = false;
|
||
|
||
for (let ki = 0; ki < kfs.length; ki++) {
|
||
const fiA = kfs[ki];
|
||
const ovA = this.overrides[fiA]?.[label];
|
||
if (!ovA) continue;
|
||
|
||
if (ki < kfs.length - 1) {
|
||
const fiB = kfs[ki + 1];
|
||
const ovB = this.overrides[fiB]?.[label];
|
||
if (!ovB || fiB < fStart || fiA > fEnd) continue;
|
||
|
||
const mode = this.tweens[fiA]?.[label] ?? this.interpolationMode;
|
||
const xA = fToX(fiA), xB = fToX(fiB);
|
||
const segPixels = Math.max(20, Math.ceil(Math.abs(xB - xA)));
|
||
|
||
for (let s = 0; s <= segPixels; s++) {
|
||
const t = s / segPixels;
|
||
let raw;
|
||
if (mode === "catmull_rom") {
|
||
const interp = this._catmullRomInterp(label, fiA, fiB, t);
|
||
raw = interp ? interp[coord] : null;
|
||
} else {
|
||
const ti = applyEasing(t, mode);
|
||
raw = ovA[coord] + (ovB[coord] - ovA[coord]) * ti;
|
||
}
|
||
if (raw === null) continue;
|
||
const val = applyNorm(raw, label, coord);
|
||
const x = fToX(fiA + t * (fiB - fiA)), y = normalVToY(val);
|
||
if (!pathStarted) { ctx.moveTo(x, y); pathStarted = true; }
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
} else {
|
||
if (fiA >= fStart-1 && fiA <= fEnd+1) {
|
||
const val = applyNorm(ovA[coord], label, coord);
|
||
const x = fToX(fiA), y = normalVToY(val);
|
||
if (!pathStarted) { ctx.moveTo(x, y); pathStarted = true; }
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Smoothed preview (only for the primary / selectedJoint)
|
||
if (this.smoothWindow > 0 && label === this.selectedJoint?.label) {
|
||
const STEPS = Math.max(2, T.vw);
|
||
ctx.strokeStyle = smoothColor; ctx.lineWidth = 2.5; ctx.setLineDash([2,2]);
|
||
ctx.beginPath(); let started = false;
|
||
for (let s = 0; s <= STEPS; s++) {
|
||
const f = fStart + (fEnd - fStart) * s / STEPS;
|
||
const fi = Math.round(f);
|
||
if (fi < 0 || fi >= this.frameCount) { started = false; continue; }
|
||
const raw = this._getSmoothedValue(label, coord, fi);
|
||
if (raw === null) { started = false; continue; }
|
||
const val = applyNorm(raw, label, coord);
|
||
const x = fToX(f), y = normalVToY(val);
|
||
if (!started) { ctx.moveTo(x,y); started=true; } else ctx.lineTo(x,y);
|
||
}
|
||
ctx.stroke(); ctx.setLineDash([]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Keyframe dots (for ALL active labels)
|
||
for (let ji = 0; ji < labels.length; ji++) {
|
||
const label = labels[ji];
|
||
const kfs = this._getKeyframesForJoint(label);
|
||
for (const fi of kfs) {
|
||
if (fi < fStart-1 || fi > fEnd+1) continue;
|
||
const ov = this.overrides[fi]?.[label]; if (!ov) continue;
|
||
const x = fToX(fi);
|
||
const isCur = fi === this.currentFrame;
|
||
for (const [coord, show, dotColor] of [
|
||
[0, this.graphShowX, "#ff6b6b"],
|
||
[1, this.graphShowY, "#50f0a0"],
|
||
]) {
|
||
if (!show) continue;
|
||
const val = applyNorm(ov[coord], label, coord);
|
||
const y = normalVToY(val);
|
||
const isCoordSel = this.graphSel.has(`${fi}::${label}::${coord}`);
|
||
ctx.fillStyle = isCoordSel ? "#5bc4ff" : isCur ? "#f7c840" : dotColor;
|
||
ctx.strokeStyle = isCoordSel ? "#9adeff" : "#0a0c14"; ctx.lineWidth = isCoordSel ? 2 : 1.5;
|
||
ctx.setLineDash([]);
|
||
ctx.beginPath(); ctx.arc(x, y, isCur ? 6 : labels.length > 1 ? 3.5 : 4.5, 0, Math.PI*2);
|
||
ctx.fill(); ctx.stroke();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Current frame badge (frame number on the cursor line)
|
||
if (cfx >= PAD.l && cfx <= cw - PAD.r) {
|
||
const badge = String(this.currentFrame);
|
||
ctx.font = "bold 10px 'JetBrains Mono',monospace";
|
||
const bw = ctx.measureText(badge).width + 8;
|
||
const bh = 14, bx = cfx - bw/2, by = PAD.t - bh - 1;
|
||
ctx.fillStyle = "#1a4870";
|
||
ctx.beginPath(); ctx.roundRect?.(bx, by, bw, bh, 3); ctx.fill();
|
||
ctx.fillStyle = "#5bc4ff"; ctx.textAlign = "center";
|
||
ctx.fillText(badge, cfx, by + bh - 3);
|
||
ctx.textAlign = "left";
|
||
}
|
||
|
||
// Normalize axis labels and grid lines
|
||
if (this.normalizeGraph) {
|
||
// Grid lines at -1, 0, +1
|
||
ctx.lineWidth = 1; ctx.setLineDash([2, 3]);
|
||
ctx.strokeStyle = "rgba(91,196,255,0.25)";
|
||
for (const v of [1, -1]) {
|
||
ctx.beginPath(); ctx.moveTo(PAD.l, normalVToY(v)); ctx.lineTo(cw-PAD.r, normalVToY(v)); ctx.stroke();
|
||
}
|
||
ctx.strokeStyle = "rgba(91,196,255,0.45)";
|
||
ctx.beginPath(); ctx.moveTo(PAD.l, normalVToY(0)); ctx.lineTo(cw-PAD.r, normalVToY(0)); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
// Axis labels
|
||
ctx.font = "bold 10px 'JetBrains Mono',monospace"; ctx.textAlign = "right";
|
||
ctx.fillStyle = "#5bc4ff"; ctx.fillText(" 1", PAD.l-2, normalVToY(1)+4);
|
||
ctx.fillStyle = "#7ad4ff"; ctx.fillText(" 0", PAD.l-2, normalVToY(0)+4);
|
||
ctx.fillStyle = "#5bc4ff"; ctx.fillText("-1", PAD.l-2, normalVToY(-1)+4);
|
||
ctx.textAlign = "left";
|
||
}
|
||
|
||
// Legend (show joint names when multiple)
|
||
ctx.font = "10px sans-serif"; let lx = PAD.l + 4;
|
||
if (this.graphShowX) {
|
||
ctx.fillStyle = getColors(0,0).bright; ctx.fillRect(lx, PAD.t+3, 10, 8);
|
||
ctx.fillStyle = "#ccc"; ctx.fillText("X", lx+12, PAD.t+11); lx += 28;
|
||
}
|
||
if (this.graphShowY) {
|
||
ctx.fillStyle = getColors(0,1).bright; ctx.fillRect(lx, PAD.t+3, 10, 8);
|
||
ctx.fillStyle = "#ccc"; ctx.fillText("Y", lx+12, PAD.t+11); lx += 28;
|
||
}
|
||
if (labels.length > 1) {
|
||
ctx.fillStyle="#99aacc"; ctx.fillText(`${labels.length} joints`, lx, PAD.t+11); lx+=60;
|
||
}
|
||
if (this.normalizeGraph) {
|
||
ctx.fillStyle="#88bbff"; ctx.fillText("normalized", lx, PAD.t+11); lx+=70;
|
||
}
|
||
if (this.smoothWindow > 0) {
|
||
ctx.fillStyle = "#99aaff"; ctx.fillText(`⟿ smooth(${this.smoothWindow})`, lx, PAD.t+11);
|
||
}
|
||
|
||
// Rubber-band selection rect
|
||
if (this._graphDragSel) {
|
||
const {startX,startY,curX,curY} = this._graphDragSel;
|
||
const rx=Math.min(startX,curX), ry=Math.min(startY,curY);
|
||
const rw=Math.abs(curX-startX), rh=Math.abs(curY-startY);
|
||
ctx.fillStyle = "rgba(80,160,255,0.08)"; ctx.fillRect(rx,ry,rw,rh);
|
||
ctx.strokeStyle = "#5599ff"; ctx.lineWidth = 1; ctx.setLineDash([3,2]);
|
||
ctx.strokeRect(rx,ry,rw,rh); ctx.setLineDash([]);
|
||
}
|
||
|
||
// S-scale mode indicator
|
||
if (this._graphScale) {
|
||
const sc = this._graphScale;
|
||
const mx = this._lastGraphMouse.x, my = this._lastGraphMouse.y;
|
||
const pivX = fToX(sc.pivotFi), pivY = normalVToY(this.normalizeGraph ? 0 : sc.pivotVal);
|
||
ctx.save();
|
||
// Draw pivot crosshair
|
||
ctx.strokeStyle = "rgba(255,200,80,0.6)"; ctx.lineWidth = 1; ctx.setLineDash([2,3]);
|
||
ctx.beginPath(); ctx.moveTo(pivX, PAD.t); ctx.lineTo(pivX, ch-PAD.b); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(PAD.l, pivY); ctx.lineTo(cw-PAD.r, pivY); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "#ffcc44"; ctx.font = "bold 11px sans-serif";
|
||
const lockStr = sc.axisLock === "time" ? " X=Time only" : sc.axisLock === "value" ? " Y=Value only" : "";
|
||
ctx.fillText(`SCALE${lockStr} | X=lock Time Y=lock Value Enter/LMB=confirm Esc=cancel`, PAD.l+6, PAD.t+22);
|
||
// Draw line from pivot to mouse
|
||
ctx.strokeStyle = "rgba(255,200,80,0.4)"; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(pivX, pivY); ctx.lineTo(mx, my); ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// G-grab mode indicator
|
||
if (this._graphGrab) {
|
||
const grab = this._graphGrab;
|
||
const mx = this._lastGraphMouse.x, my = this._lastGraphMouse.y;
|
||
ctx.save();
|
||
if (grab.axisLock === "time") {
|
||
// Locked to time: draw horizontal guide at grab start Y
|
||
ctx.strokeStyle = "rgba(255,140,30,0.7)"; ctx.lineWidth = 1.5; ctx.setLineDash([5,3]);
|
||
ctx.beginPath(); ctx.moveTo(PAD.l, grab.startY); ctx.lineTo(cw-PAD.r, grab.startY); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "#ff8c1e"; ctx.font = "bold 11px sans-serif";
|
||
ctx.fillText("GRAB | X = Time axis (press X to unlock)", PAD.l+6, PAD.t+22);
|
||
} else if (grab.axisLock === "value") {
|
||
// Locked to value: draw vertical guide at grab start X
|
||
ctx.strokeStyle = "rgba(30,180,255,0.7)"; ctx.lineWidth = 1.5; ctx.setLineDash([5,3]);
|
||
ctx.beginPath(); ctx.moveTo(grab.startX, PAD.t); ctx.lineTo(grab.startX, ch-PAD.b); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "#1ec8ff"; ctx.font = "bold 11px sans-serif";
|
||
ctx.fillText("GRAB | Y = Value axis (press Y to unlock)", PAD.l+6, PAD.t+22);
|
||
} else {
|
||
ctx.fillStyle = "#ffcc44"; ctx.font = "bold 11px sans-serif";
|
||
ctx.fillText("GRAB | X=lock Time Y=lock Value Enter/LMB=confirm Esc=cancel", PAD.l+6, PAD.t+22);
|
||
}
|
||
// Draw crosshair at mouse position
|
||
ctx.strokeStyle = "rgba(255,220,80,0.6)"; ctx.lineWidth = 1; ctx.setLineDash([2,2]);
|
||
ctx.beginPath(); ctx.moveTo(mx, PAD.t); ctx.lineTo(mx, ch-PAD.b); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(PAD.l, my); ctx.lineTo(cw-PAD.r, my); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
_graphFrameStep() {
|
||
const range = this.graphViewport.frameEnd - this.graphViewport.frameStart;
|
||
if (range <= 20) return 1;
|
||
if (range <= 100) return 10;
|
||
if (range <= 500) return 50;
|
||
return 100;
|
||
}
|
||
_graphValStep() {
|
||
const range = this.graphViewport.valMax - this.graphViewport.valMin;
|
||
if (range <= 50) return 10;
|
||
if (range <= 200) return 50;
|
||
if (range <= 1000) return 100;
|
||
return 200;
|
||
}
|
||
|
||
_onGraphMouseDown(e) {
|
||
const rect = this.graphCanvas.getBoundingClientRect();
|
||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||
this._lastGraphMouse = {x: cx, y: cy};
|
||
const T = this._graphTransform();
|
||
|
||
// Confirm/cancel G-grab with LMB/RMB
|
||
if (this._graphGrab) {
|
||
e.preventDefault();
|
||
if (e.button === 0) this._confirmGraphGrab();
|
||
else if (e.button === 2) this._cancelGraphGrab();
|
||
return;
|
||
}
|
||
|
||
// Middle mouse (button 1): start viewport pan
|
||
if (e.button === 1) {
|
||
e.preventDefault();
|
||
const gv = this.graphViewport;
|
||
this._graphPanDrag = { startX: cx, startY: cy,
|
||
origFStart: gv.frameStart, origFEnd: gv.frameEnd,
|
||
origVMin: gv.valMin, origVMax: gv.valMax };
|
||
return;
|
||
}
|
||
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
|
||
// Ctrl+LMB: insert keyframe at cursor position for active channels
|
||
if (e.ctrlKey) {
|
||
this._stopPlayback();
|
||
const fi = Math.max(0, Math.min(this.frameCount-1, Math.round(T.xToF(cx))));
|
||
this._pushUndo();
|
||
for (const label of this._graphLabels()) {
|
||
if (this.lockedLayers.has(label)) continue;
|
||
const fd = this._getEffectiveFrame(fi); if (!fd) continue;
|
||
const { group: grp, index: ki } = _splitLabel(label);
|
||
const pts = _grpPts(fd, grp);
|
||
if (!pts?.[ki]) continue;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
if (!this.overrides[fi][label]) this.overrides[fi][label] = [...pts[ki]];
|
||
}
|
||
this._refreshTimeline(); this._renderGraphEditor(); this._seekFrame(fi);
|
||
return;
|
||
}
|
||
|
||
// Use the same normalization factors as the last render pass so hit-test matches visuals
|
||
const _nf = this._lastNormFactors || {};
|
||
const kvToY = (label, coord, rawVal) => {
|
||
if (!this.normalizeGraph) return T.vToY(rawVal);
|
||
const nf = _nf[`${label}_${coord}`];
|
||
if (!nf || nf.halfRange === 0) return T.PAD.t + T.vh / 2;
|
||
const nv = (rawVal - nf.mean) / nf.halfRange;
|
||
return T.PAD.t + (1 - (nv + 1) / 2) * T.vh;
|
||
};
|
||
|
||
// Hit-test dots across ALL active labels
|
||
const THRESH = 9;
|
||
let hitFi = -1, hitLabel = null, hitCoord = -1;
|
||
outer: for (const label of this._graphLabels()) {
|
||
for (const fi of this._getKeyframesForJoint(label)) {
|
||
const ov = this.overrides[fi]?.[label]; if (!ov) continue;
|
||
const x = T.fToX(fi);
|
||
for (const [coord, show] of [[0, this.graphShowX], [1, this.graphShowY]]) {
|
||
if (!show) continue;
|
||
const y = kvToY(label, coord, ov[coord]);
|
||
if (Math.hypot(cx-x, cy-y) <= THRESH) { hitFi=fi; hitLabel=label; hitCoord=coord; break outer; }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hitFi >= 0) {
|
||
this._stopPlayback();
|
||
const gKey = `${hitFi}::${hitLabel}::${hitCoord}`;
|
||
if (e.shiftKey) {
|
||
if (this.graphSel.has(gKey)) this.graphSel.delete(gKey); else this.graphSel.add(gKey);
|
||
} else if (!this.graphSel.has(gKey)) {
|
||
this.graphSel.clear(); this.graphSel.add(gKey);
|
||
}
|
||
this._seekFrame(hitFi);
|
||
this._startGraphDrag(cx, cy, e.shiftKey);
|
||
return;
|
||
}
|
||
|
||
// No dot hit: start rubber-band (clear selection if not shift)
|
||
if (!e.shiftKey) this.graphSel.clear();
|
||
this._graphDragSel = { startX: cx, startY: cy, curX: cx, curY: cy };
|
||
const fi = Math.round(T.xToF(cx));
|
||
if (fi >= 0 && fi < this.frameCount) this._seekFrame(fi);
|
||
}
|
||
|
||
_onGraphMouseMove(e) {
|
||
const rect = this.graphCanvas.getBoundingClientRect();
|
||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||
this._lastGraphMouse = {x: cx, y: cy};
|
||
|
||
if (this._graphGrab) { this._applyGraphGrab(cx, cy); return; }
|
||
if (this._graphScale) { this._applyGraphScale(cx, cy); return; }
|
||
|
||
if (this._graphPanDrag) {
|
||
const { startX, startY, origFStart, origFEnd, origVMin, origVMax } = this._graphPanDrag;
|
||
const T = this._graphTransform();
|
||
const fRange = origFEnd - origFStart, vRange = origVMax - origVMin;
|
||
const dF = -(cx - startX) / T.vw * fRange;
|
||
const dV = (cy - startY) / T.vh * vRange;
|
||
this.graphViewport.frameStart = Math.max(0, origFStart + dF);
|
||
this.graphViewport.frameEnd = Math.min(this.frameCount-1, origFEnd + dF);
|
||
this.graphViewport.valMin = origVMin + dV;
|
||
this.graphViewport.valMax = origVMax + dV;
|
||
this._renderGraphEditor(); return;
|
||
}
|
||
|
||
if (this._graphDragSel) {
|
||
this._graphDragSel.curX = cx; this._graphDragSel.curY = cy;
|
||
this._renderGraphEditor(); return;
|
||
}
|
||
|
||
if (this.graphDrag) { e.preventDefault(); this._applyGraphDrag(cx, cy); }
|
||
}
|
||
|
||
_onGraphMouseUp(e) {
|
||
if (this._graphGrab && e.button === 0) { this._confirmGraphGrab(); return; }
|
||
if (this._graphGrab && e.button === 2) { this._cancelGraphGrab(); return; }
|
||
if (this._graphScale && e.button === 0) { this._confirmGraphScale(); return; }
|
||
if (this._graphScale && e.button === 2) { this._cancelGraphScale(); return; }
|
||
|
||
if (this._graphPanDrag) { this._graphPanDrag = null; return; }
|
||
|
||
if (this._graphDragSel) {
|
||
const sel = this._graphDragSel; this._graphDragSel = null;
|
||
const T = this._graphTransform();
|
||
const labels = this._graphLabels();
|
||
const minX = Math.min(sel.startX, sel.curX), maxX = Math.max(sel.startX, sel.curX);
|
||
const minY = Math.min(sel.startY, sel.curY), maxY = Math.max(sel.startY, sel.curY);
|
||
if (!e.shiftKey) this.graphSel.clear();
|
||
// Normalize-aware Y helper (same as in _onGraphMouseDown)
|
||
const _nCache = {};
|
||
const rbKvToY = (label, coord, rawVal) => {
|
||
if (!this.normalizeGraph) return T.vToY(rawVal);
|
||
const key = `${label}_${coord}`;
|
||
if (!_nCache[key]) {
|
||
let mn = Infinity, mx = -Infinity;
|
||
for (const fi2 of this._getKeyframesForJoint(label)) {
|
||
const ov2 = this.overrides[fi2]?.[label];
|
||
if (ov2) { mn = Math.min(mn, ov2[coord]); mx = Math.max(mx, ov2[coord]); }
|
||
}
|
||
_nCache[key] = { mn, mx };
|
||
}
|
||
const { mn, mx } = _nCache[key];
|
||
if (!isFinite(mn) || mx === mn) return T.PAD.t + T.vh / 2;
|
||
const nv = (rawVal - mn) / (mx - mn) * 2 - 1;
|
||
return T.PAD.t + (1 - (nv + 1) / 2) * T.vh;
|
||
};
|
||
for (const label of labels) {
|
||
const isCamSel = this._isCamScalarLabel(label);
|
||
for (const fi of this._getKeyframesForJoint(label)) {
|
||
const ov = this.overrides[fi]?.[label]; if (!ov) continue;
|
||
const x = T.fToX(fi);
|
||
const coordPairs = isCamSel ? [[0, true]] : [[0, this.graphShowX], [1, this.graphShowY]];
|
||
for (const [coord, show] of coordPairs) {
|
||
if (!show) continue;
|
||
const y = rbKvToY(label, coord, ov[coord]);
|
||
if (x >= minX && x <= maxX && y >= minY && y <= maxY)
|
||
this.graphSel.add(`${fi}::${label}::${coord}`);
|
||
}
|
||
}
|
||
}
|
||
this._renderGraphEditor(); this._renderTrack(); return;
|
||
}
|
||
|
||
if (this.graphDrag) {
|
||
// Update graphSel keys to reflect final frame positions
|
||
const { kfEntries, lastFiDelta } = this.graphDrag;
|
||
if (lastFiDelta !== 0) {
|
||
const newSel = new Set();
|
||
for (const key of this.graphSel) {
|
||
const [fStr, lbl, cStr] = key.split("::");
|
||
const origFi = parseInt(fStr);
|
||
const entry = kfEntries.find(en => en.origFi === origFi && en.label === lbl);
|
||
const newFi = entry ? Math.max(0, Math.min(this.frameCount-1, entry.origFi + lastFiDelta)) : origFi;
|
||
newSel.add(`${newFi}::${lbl}::${cStr}`);
|
||
}
|
||
this.graphSel = newSel;
|
||
}
|
||
this.graphDrag = null; this._dragPreState = null;
|
||
this._refreshTimeline(); this._renderGraphEditor(); this._renderTrack();
|
||
}
|
||
}
|
||
|
||
_onGraphWheel(e) {
|
||
e.preventDefault();
|
||
const { graphViewport: vp } = this;
|
||
const T = this._graphTransform();
|
||
const rect = this.graphCanvas.getBoundingClientRect();
|
||
const cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
||
const fRange = vp.frameEnd - vp.frameStart;
|
||
const vRange = vp.valMax - vp.valMin;
|
||
|
||
if (e.ctrlKey && e.shiftKey) {
|
||
// Ctrl+Shift+Wheel: vertical zoom around cursor value
|
||
const factor = e.deltaY > 0 ? 1.15 : 0.87;
|
||
const pivot = T.yToV(cy);
|
||
vp.valMin = pivot - (pivot - vp.valMin) * factor;
|
||
vp.valMax = pivot + (vp.valMax - pivot) * factor;
|
||
} else if (e.ctrlKey) {
|
||
// Ctrl+Wheel: horizontal zoom around cursor frame
|
||
const factor = e.deltaY > 0 ? 1.15 : 0.87;
|
||
const pivot = T.xToF(cx);
|
||
vp.frameStart = Math.max(0, pivot - (pivot - vp.frameStart) * factor);
|
||
vp.frameEnd = Math.min(this.frameCount-1, pivot + (vp.frameEnd - pivot) * factor);
|
||
} else if (e.shiftKey) {
|
||
// Shift+Wheel: vertical pan
|
||
const shift = (e.deltaY > 0 ? 0.1 : -0.1) * vRange;
|
||
vp.valMin += shift; vp.valMax += shift;
|
||
} else {
|
||
// Plain Wheel: horizontal pan
|
||
const shift = (e.deltaY > 0 ? 0.1 : -0.1) * fRange;
|
||
vp.frameStart = Math.max(0, vp.frameStart + shift);
|
||
vp.frameEnd = Math.min(this.frameCount-1, vp.frameEnd + shift);
|
||
}
|
||
this._renderGraphEditor();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Graph editor — grab / drag helpers
|
||
// -----------------------------------------------------------------------
|
||
|
||
/** Build kfEntries from current graphSel and return them (unique fi::label pairs). */
|
||
_graphKfEntriesFromSel() {
|
||
const kfMap = new Map();
|
||
for (const key of this.graphSel) {
|
||
const [fStr, label] = key.split("::");
|
||
const fi = parseInt(fStr), mapKey = `${fi}::${label}`;
|
||
if (!kfMap.has(mapKey)) {
|
||
const ov = this.overrides[fi]?.[label];
|
||
if (ov) kfMap.set(mapKey, { origFi: fi, label, origVals: [...ov] });
|
||
}
|
||
}
|
||
return [...kfMap.values()];
|
||
}
|
||
|
||
/**
|
||
* Apply a combined time+value delta to graph keyframe entries.
|
||
* Uses origFi/origVals as the reference → calling repeatedly with different
|
||
* deltas always produces the correct result (no cumulative drift).
|
||
*/
|
||
_applyGraphDelta(kfEntries, fiDelta, dVal, lastFiDelta, duplicate = false) {
|
||
for (const entry of kfEntries) {
|
||
// Clear the position from the previous iteration.
|
||
// In duplicate mode: never touch origFi — it holds the original KF.
|
||
const prevFi = Math.max(0, Math.min(this.frameCount-1, entry.origFi + lastFiDelta));
|
||
if (!duplicate || prevFi !== entry.origFi) {
|
||
if (this.overrides[prevFi]?.[entry.label] !== undefined) {
|
||
delete this.overrides[prevFi][entry.label];
|
||
if (Object.keys(this.overrides[prevFi]).length === 0) delete this.overrides[prevFi];
|
||
}
|
||
}
|
||
// Write copy to new position (skip if it would land on the original — already there)
|
||
const newFi = Math.max(0, Math.min(this.frameCount-1, entry.origFi + fiDelta));
|
||
if (!duplicate || newFi !== entry.origFi) {
|
||
if (!this.overrides[newFi]) this.overrides[newFi] = {};
|
||
const newVals = [...entry.origVals];
|
||
if (this.graphSel.has(`${entry.origFi}::${entry.label}::0`)) newVals[0] = entry.origVals[0] + dVal;
|
||
if (this.graphSel.has(`${entry.origFi}::${entry.label}::1`)) newVals[1] = entry.origVals[1] + dVal;
|
||
this.overrides[newFi][entry.label] = newVals;
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Start a mouse-drag on a graph dot (LMB held). Shift = duplicate mode. */
|
||
_startGraphDrag(startX, startY, shiftKey = false) {
|
||
const kfEntries = this._graphKfEntriesFromSel();
|
||
if (kfEntries.length === 0) return;
|
||
this._armUndo();
|
||
this.graphDrag = { startX, startY, kfEntries, lastFiDelta: 0, axisLock: null, duplicate: !!shiftKey };
|
||
}
|
||
|
||
_applyGraphDrag(cx, cy) {
|
||
const { startX, startY, kfEntries, lastFiDelta, axisLock, duplicate } = this.graphDrag;
|
||
const T = this._graphTransform();
|
||
const gv = this.graphViewport;
|
||
const fDelta = axisLock === "value" ? 0
|
||
: Math.round((cx - startX) / T.vw * (gv.frameEnd - gv.frameStart));
|
||
const dVal = axisLock === "time" ? 0
|
||
: -(cy - startY) / T.vh * (gv.valMax - gv.valMin);
|
||
if (fDelta === lastFiDelta && Math.abs(dVal) < 0.05) return;
|
||
this._lazyPushUndo();
|
||
this._applyGraphDelta(kfEntries, fDelta, dVal, lastFiDelta, duplicate);
|
||
this.graphDrag.lastFiDelta = fDelta;
|
||
this._renderGraphEditor(); this._renderFrame(this.currentFrame); this._renderTrack();
|
||
}
|
||
|
||
/** Start G-grab mode (keyboard G shortcut — mouse not held). */
|
||
_startGraphGrab() {
|
||
if (this.graphSel.size === 0) return;
|
||
const kfEntries = this._graphKfEntriesFromSel();
|
||
if (kfEntries.length === 0) return;
|
||
this._pushUndo(); // save state; cancelled with _undo()
|
||
this._graphGrab = {
|
||
kfEntries,
|
||
startX: this._lastGraphMouse.x,
|
||
startY: this._lastGraphMouse.y,
|
||
lastFiDelta: 0,
|
||
axisLock: null,
|
||
};
|
||
}
|
||
|
||
_applyGraphGrab(cx, cy) {
|
||
const grab = this._graphGrab; if (!grab) return;
|
||
const T = this._graphTransform();
|
||
const gv = this.graphViewport;
|
||
const fDelta = grab.axisLock === "value" ? 0
|
||
: Math.round((cx - grab.startX) / T.vw * (gv.frameEnd - gv.frameStart));
|
||
const dVal = grab.axisLock === "time" ? 0
|
||
: -(cy - grab.startY) / T.vh * (gv.valMax - gv.valMin);
|
||
this._applyGraphDelta(grab.kfEntries, fDelta, dVal, grab.lastFiDelta);
|
||
grab.lastFiDelta = fDelta;
|
||
this._renderGraphEditor(); this._renderFrame(this.currentFrame); this._renderTrack();
|
||
}
|
||
|
||
_confirmGraphGrab() {
|
||
if (!this._graphGrab) return;
|
||
const { kfEntries, lastFiDelta } = this._graphGrab;
|
||
// Update graphSel keys to point to new frame indices
|
||
if (lastFiDelta !== 0) {
|
||
const newSel = new Set();
|
||
for (const key of this.graphSel) {
|
||
const [fStr, lbl, cStr] = key.split("::");
|
||
const origFi = parseInt(fStr);
|
||
const entry = kfEntries.find(en => en.origFi === origFi && en.label === lbl);
|
||
const newFi = entry ? Math.max(0, Math.min(this.frameCount-1, entry.origFi + lastFiDelta)) : origFi;
|
||
newSel.add(`${newFi}::${lbl}::${cStr}`);
|
||
}
|
||
this.graphSel = newSel;
|
||
}
|
||
this._graphGrab = null;
|
||
this._refreshTimeline(); this._renderGraphEditor(); this._renderTrack();
|
||
}
|
||
|
||
_cancelGraphGrab() {
|
||
if (!this._graphGrab) return;
|
||
this._graphGrab = null;
|
||
this._undo(); // restores pre-grab state (pushed in _startGraphGrab)
|
||
}
|
||
|
||
/** Delete all keyframes referenced by graphSel. */
|
||
_deleteGraphSelected() {
|
||
if (this.graphSel.size === 0) return;
|
||
const pairs = new Set();
|
||
for (const key of this.graphSel) {
|
||
const [fStr, label] = key.split("::");
|
||
pairs.add(`${fStr}::${label}`);
|
||
}
|
||
this._pushUndo();
|
||
for (const pair of pairs) {
|
||
const [fStr, label] = pair.split("::");
|
||
this._deleteKeyframeRaw(label, parseInt(fStr));
|
||
}
|
||
this.graphSel.clear();
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame); this._renderGraphEditor();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Graph editor — Smooth Selected + Scale mode + Help
|
||
// -----------------------------------------------------------------------
|
||
|
||
/**
|
||
* Gaussian-smooth the VALUE of selected keyframes.
|
||
* Only selected KFs are modified; unselected KFs act as fixed anchors.
|
||
* Window is determined by the `smoothWindow` slider (falls back to 5 if 0).
|
||
*/
|
||
_smoothSelectedKfs() {
|
||
if (this.graphSel.size === 0) return;
|
||
const radius = Math.max(2, Math.floor((this.smoothWindow > 0 ? this.smoothWindow : 5) / 2));
|
||
// Group selected KFs by label+coord
|
||
const groups = new Map();
|
||
for (const key of this.graphSel) {
|
||
const [fStr, label, cStr] = key.split("::");
|
||
const gk = `${label}::${cStr}`;
|
||
if (!groups.has(gk)) groups.set(gk, new Set());
|
||
groups.get(gk).add(parseInt(fStr));
|
||
}
|
||
this._pushUndo();
|
||
for (const [gk, selFis] of groups) {
|
||
const [label, cStr] = gk.split("::"); const coord = parseInt(cStr);
|
||
const allKfs = this._getKeyframesForJoint(label);
|
||
if (allKfs.length < 3) continue;
|
||
// Build sorted array of {fi, val}
|
||
const samples = allKfs.map(fi => ({ fi, val: this.overrides[fi]?.[label]?.[coord] ?? 0 }));
|
||
const n = samples.length;
|
||
const sigma = radius / 2;
|
||
for (const fi of selFis) {
|
||
const idx = samples.findIndex(s => s.fi === fi); if (idx < 0) continue;
|
||
let wx = 0, wsum = 0;
|
||
for (let d = -radius; d <= radius; d++) {
|
||
const j = idx + d; if (j < 0 || j >= n) continue;
|
||
const w = Math.exp(-0.5 * (d / sigma) ** 2);
|
||
wx += w * samples[j].val; wsum += w;
|
||
}
|
||
const smoothed = wsum > 0 ? wx / wsum : samples[idx].val;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
if (!this.overrides[fi][label]) {
|
||
const fd = this._getEffectiveFrame(fi);
|
||
const { group: grp, index: ki } = _splitLabel(label);
|
||
const pts = _grpPts(fd, grp);
|
||
this.overrides[fi][label] = pts?.[ki] ? [...pts[ki]] : [0,0,1];
|
||
}
|
||
this.overrides[fi][label][coord] = smoothed;
|
||
}
|
||
}
|
||
this._renderGraphEditor(); this._renderFrame(this.currentFrame); this._renderTrack();
|
||
}
|
||
|
||
/** Start S-Scale mode for selected graph keyframes. */
|
||
_startGraphScale() {
|
||
if (this.graphSel.size === 0) return;
|
||
const kfEntries = this._graphKfEntriesFromSel(); if (kfEntries.length === 0) return;
|
||
// Pivot = median frame + median value of selected KFs
|
||
const fiArr = [], valArr = [];
|
||
for (const entry of kfEntries) {
|
||
fiArr.push(entry.origFi);
|
||
if (this.graphSel.has(`${entry.origFi}::${entry.label}::0`)) valArr.push(entry.origVals[0]);
|
||
if (this.graphSel.has(`${entry.origFi}::${entry.label}::1`)) valArr.push(entry.origVals[1]);
|
||
}
|
||
const pivotFi = fiArr.reduce((a,b)=>a+b,0)/fiArr.length;
|
||
const pivotVal = valArr.length ? valArr.reduce((a,b)=>a+b,0)/valArr.length : 0;
|
||
this._pushUndo();
|
||
this._graphScale = { kfEntries, pivotFi, pivotVal,
|
||
startX: this._lastGraphMouse.x, startY: this._lastGraphMouse.y,
|
||
lastFiDelta: 0, lastValScale: 1, axisLock: null };
|
||
}
|
||
|
||
_applyGraphScale(cx, cy) {
|
||
const sc = this._graphScale; if (!sc) return;
|
||
const T = this._graphTransform(); const gv = this.graphViewport;
|
||
const dx = cx - sc.startX, dy = sc.startY - cy; // up = positive
|
||
const fRange = gv.frameEnd - gv.frameStart, vRange = gv.valMax - gv.valMin;
|
||
// Scale factors: mouse distance from center → multiplier
|
||
const timeScale = sc.axisLock === "value" ? 1 : 1 + dx / T.vw * 2;
|
||
const valScale = sc.axisLock === "time" ? 1 : 1 + dy / T.vh * 2;
|
||
// Restore original, then apply new scale (all from origFi/origVals)
|
||
for (const entry of sc.kfEntries) {
|
||
const prevFi = Math.max(0, Math.min(this.frameCount-1,
|
||
Math.round(sc.pivotFi + (entry.origFi - sc.pivotFi) * sc.lastValScale)));
|
||
if (this.overrides[prevFi]?.[entry.label]) {
|
||
delete this.overrides[prevFi][entry.label];
|
||
if (Object.keys(this.overrides[prevFi]).length===0) delete this.overrides[prevFi];
|
||
}
|
||
}
|
||
for (const entry of sc.kfEntries) {
|
||
const newFi = Math.max(0, Math.min(this.frameCount-1,
|
||
Math.round(sc.pivotFi + (entry.origFi - sc.pivotFi) * timeScale)));
|
||
if (!this.overrides[newFi]) this.overrides[newFi] = {};
|
||
const nv = [...entry.origVals];
|
||
if (this.graphSel.has(`${entry.origFi}::${entry.label}::0`))
|
||
nv[0] = sc.pivotVal + (entry.origVals[0] - sc.pivotVal) * valScale;
|
||
if (this.graphSel.has(`${entry.origFi}::${entry.label}::1`))
|
||
nv[1] = sc.pivotVal + (entry.origVals[1] - sc.pivotVal) * valScale;
|
||
this.overrides[newFi][entry.label] = nv;
|
||
}
|
||
sc.lastValScale = timeScale; // reuse field for timeScale restore
|
||
this._renderGraphEditor(); this._renderFrame(this.currentFrame); this._renderTrack();
|
||
}
|
||
|
||
_confirmGraphScale() {
|
||
if (!this._graphScale) return;
|
||
this._graphScale = null;
|
||
this._refreshTimeline(); this._renderGraphEditor();
|
||
}
|
||
_cancelGraphScale() {
|
||
if (!this._graphScale) return;
|
||
this._graphScale = null;
|
||
this._undo();
|
||
}
|
||
|
||
/** Show a help modal with all keyboard shortcuts. */
|
||
_showHelp() {
|
||
if (document.getElementById("_twHelp")) return;
|
||
const modal = document.createElement("div");
|
||
modal.id = "_twHelp";
|
||
Object.assign(modal.style, {
|
||
position:"fixed", inset:"0", zIndex:"30000", background:"rgba(0,0,0,0.75)",
|
||
display:"flex", alignItems:"center", justifyContent:"center",
|
||
});
|
||
const panel = document.createElement("div");
|
||
Object.assign(panel.style, {
|
||
background:"#0e1020", border:"1px solid #2c3352", borderRadius:"8px",
|
||
padding:"18px 22px", maxWidth:"760px", width:"90%", maxHeight:"82vh",
|
||
overflowY:"auto", color:"#b0bcd4", fontSize:"12px", lineHeight:"1.7",
|
||
fontFamily:"'JetBrains Mono','Inter',monospace", boxShadow:"0 8px 40px rgba(0,0,0,0.8)",
|
||
});
|
||
const sections = [
|
||
["Universal", [
|
||
["Space", "Play / Pause"],
|
||
["← / →", "Step frame back / forward"],
|
||
["Home", "First frame (Dope Sheet) / Fit all (Graph)"],
|
||
["End", "Last frame"],
|
||
["Ctrl+Z", "Undo"],
|
||
["Ctrl+Y / Ctrl+Shift+Z", "Redo"],
|
||
["Tab", "Toggle Dope Sheet ↔ Graph Editor"],
|
||
["H", "Hide selected joints"],
|
||
["Alt+H", "Unhide all joints"],
|
||
["F1", "Open / close this help"],
|
||
]],
|
||
["Layouts & Viewports", [
|
||
["⊡ 1 / ⊟ 2 / ⊞ 4", "Switch panel layout (1, 2, or 4 viewports)"],
|
||
["Click panel header", "Open view-picker dropdown (Front/Back/Top/Side/Orbit/Camera)"],
|
||
["Click inside panel", "Make that panel the active one (blue accent border)"],
|
||
["F-badge (top-right)", "Current frame / total frame count"],
|
||
["Axis gizmo (orbit)", "Bottom-left X/Y/Z indicator — back axes dimmed"],
|
||
]],
|
||
["Viewport — Front / Back", [
|
||
["Drag joint", "Move joint (Auto Key writes a keyframe)"],
|
||
["Shift+click joint", "Add to multi-selection"],
|
||
["Ctrl+drag empty", "Box-select joints"],
|
||
["RMB on joint", "Disable / enable joint (confidence = 0)"],
|
||
["K", "Commit keyframe (when Auto Key is OFF)"],
|
||
["Scroll", "Zoom in / out"],
|
||
["Middle / Alt-drag", "Pan canvas"],
|
||
["Dbl-click", "Reset zoom and pan"],
|
||
]],
|
||
["Viewport — Top / Side", [
|
||
["Drag joint horizontal", "Top: pose X | Side: Z-depth"],
|
||
["Drag joint vertical", "Top: Z-depth | Side: pose Y"],
|
||
["Scroll", "Zoom"],
|
||
["Middle / Alt-drag", "Pan"],
|
||
]],
|
||
["Viewport — Orbit (3D)", [
|
||
["Drag empty", "Rotate (orbit yaw/pitch)"],
|
||
["Drag joint", "Move joint freely in camera-screen space"],
|
||
["Scroll", "Zoom"],
|
||
["Middle-drag", "Pan"],
|
||
["Axis gizmo bubbles", "X = red, Y = green, Z = blue (world axes)"],
|
||
]],
|
||
["Viewport — Camera", [
|
||
["Click ◎ Camera", "Add a scene camera (creates KFs for X/Y/Z/Roll/Tilt/Pan/FOV)"],
|
||
["Drag camera aim", "Reposition camera target in front/top/side views"],
|
||
["Lock to View", "Edit camera by orbiting in the camera viewport"],
|
||
["cam_z slider", "Pull camera closer / further"],
|
||
["cam_fov slider", "Wider / tighter lens"],
|
||
]],
|
||
["Pose Groups", [
|
||
["Body (20 joints)", "OpenPose 18 + 2 toes — colored by side (R red/blue, L green/magenta)"],
|
||
["R Hand (21 joints)", "Right hand fingers, orange (#ff6400)"],
|
||
["L Hand (21 joints)", "Left hand fingers, green (#64c800)"],
|
||
["Face (70 joints)", "300W landmarks, yellow (#ffd44c) — toggle in Face group eye icon"],
|
||
["NLF Body (18 joints)", "SMPL 3D joints (NLF mode only) — purple, fully keyframable"],
|
||
["IK / FK on hand", "IK: fingers follow wrist · FK: independent finger joints"],
|
||
]],
|
||
["DWPose / NLF Modes", [
|
||
["Tab: DWPose", "Dope Sheet + Graph Editor show DWPose body / hands / face"],
|
||
["Tab: NLF", "Dope Sheet + Graph Editor switch to 18 SMPL joints (NLF only)"],
|
||
["📊 Edit NLF Data", "Sidebar shortcut to jump straight into NLF mode"],
|
||
["NLF availability", "NLF tab is active only if an NLF model was selected on the Extractor"],
|
||
]],
|
||
["Ref Frame", [
|
||
["⊕ Ref Frame", "Capture clean front-view snapshot of frame 0 (camera-immune)"],
|
||
["Edit in Ref mode", "Fix badly detected joints without camera distortion"],
|
||
["✕ Exit Ref Frame", "Return to the main sequence"],
|
||
["Apply", "Save Ref Frame fixes back through the workflow"],
|
||
["Retargeter side", "Set Reference Source = Ref Frame for clean slider calibration"],
|
||
]],
|
||
["Reference Card", [
|
||
["🖼 Image", "Load a still image as the underlay"],
|
||
["🎬 Video", "Load a video file (syncs to the timeline)"],
|
||
["🎞 Seq", "Load an image sequence (multi-select in file picker)"],
|
||
["Offset", "Per-frame offset between reference and pose data"],
|
||
["× Clear", "Remove the current reference"],
|
||
["👁 Reference: ON/OFF", "Toggle visibility of the underlay"],
|
||
]],
|
||
["Output Canvas", [
|
||
["W × H fields", "Show effective output size (input dims by default)"],
|
||
["Input preset", "Revert to the input pose dimensions"],
|
||
["Size presets", "512 / 768 / 1024 / 768×1024 / 1024×768 / 1280×720"],
|
||
["Effect", "Sets pose_data dims; drives camera intrinsic K (cx, cy, focal)"],
|
||
]],
|
||
["3D Depth (Experimental)", [
|
||
["⬡ Turn to 3D", "Auto-switches to 4 viewports + camera; enables NLF overlay"],
|
||
["DWPose Opacity", "Fade DWPose skeleton to compare against NLF"],
|
||
["NLF Overlay Opacity", "Show / hide NLF 3D ghost (purple)"],
|
||
["3D Bone Radius", "Cylinder thickness in 3D orbit / camera views"],
|
||
["⬇ Bake Z Depth", "Write NLF Z into all body keyframes (XY untouched)"],
|
||
["⬆ Unbake Z Depth", "Clear baked Z depth from all keyframes"],
|
||
]],
|
||
["Project / Scene", [
|
||
["File → New Scene", "Blank T-pose scene; checkbox adds default 70-pt face"],
|
||
["File → Edit Project", "Change W/H/frames/FPS without losing keyframes"],
|
||
["+ Add → Hand", "Synthesised hand pose at the wrist"],
|
||
["+ Add → Camera", "Adds the ◎ Camera group with full keyframable rig"],
|
||
["💾 Save", "Download editor state (overrides + Z + tweens + Ref Frame) to .json"],
|
||
["📂 Load", "Restore a previously saved .json project file"],
|
||
["Apply", "Write all edits back through the workflow"],
|
||
]],
|
||
["Graph Editor — Navigation", [
|
||
["Scroll", "Pan horizontally"],
|
||
["Shift+Scroll", "Pan vertically"],
|
||
["Ctrl+Scroll", "Zoom horizontal (around cursor)"],
|
||
["Ctrl+Shift+Scroll", "Zoom vertical (around cursor)"],
|
||
["Middle-drag", "Free pan"],
|
||
[". (period)", "Fit selected keyframes in view"],
|
||
["Home", "Fit all frames in view"],
|
||
]],
|
||
["Graph Editor — Selection", [
|
||
["LMB click", "Select keyframe (X or Y curve)"],
|
||
["Shift+LMB", "Add / remove from selection"],
|
||
["Drag on empty", "Rubber-band select"],
|
||
["A", "Select all / Deselect all (toggle)"],
|
||
["Ctrl+A", "Add all to selection"],
|
||
["Ctrl+LMB", "Insert keyframe at clicked position"],
|
||
["Escape", "Deselect all / Cancel operation"],
|
||
]],
|
||
["Graph Editor — Editing", [
|
||
["K / I", "Insert keyframe at current frame"],
|
||
["X / Delete", "Delete selected keyframes"],
|
||
["G", "Grab — move selected KFs (time + value)"],
|
||
["G → X / Y", "Grab locked to time / value axis"],
|
||
["Enter / LMB", "Confirm Grab or Scale"],
|
||
["Esc / RMB", "Cancel Grab or Scale"],
|
||
["S", "Scale KFs around selection center"],
|
||
["S → X / Y", "Scale time / value axis only"],
|
||
["O", "Gaussian smooth selected keyframe values"],
|
||
]],
|
||
["Dope Sheet", [
|
||
["LMB on track", "Seek to frame"],
|
||
["LMB on diamond", "Select keyframe"],
|
||
["Shift+LMB", "Add to selection"],
|
||
["Drag on track", "Rubber-band select"],
|
||
["Drag selection", "Move keyframes in time"],
|
||
["A", "Select all / Deselect all"],
|
||
["K / I", "Insert keyframe at current frame"],
|
||
["X / Delete", "Delete selected keyframes"],
|
||
["Ctrl+C / V", "Copy / Paste selected keyframes"],
|
||
["◀K✕ Before", "Trim all KFs before cursor"],
|
||
["✕K▶ After", "Trim all KFs after cursor"],
|
||
]],
|
||
];
|
||
let html = `<div style="font-size:14px;font-weight:700;color:#d4ddf4;margin-bottom:12px;border-bottom:1px solid #2c3352;padding-bottom:8px;letter-spacing:0.04em;">
|
||
Magos Pose Editor — Help & Shortcuts</div>`;
|
||
html += `<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px;">`;
|
||
for (const [sectionTitle, entries] of sections) {
|
||
html += `<div style="margin-bottom:10px;"><div style="color:#5bc4ff;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px;">${sectionTitle}</div>`;
|
||
html += `<table style="width:100%;border-collapse:collapse;">`;
|
||
for (const [key, desc] of entries) {
|
||
html += `<tr><td style="color:#f7c840;padding:1px 6px 1px 0;white-space:nowrap;">${key}</td><td style="color:#a0b0c8;">${desc}</td></tr>`;
|
||
}
|
||
html += `</table></div>`;
|
||
}
|
||
html += `</div>`;
|
||
// Keyframe icon legend
|
||
html += `<div style="margin-top:12px;border-top:1px solid #2c3352;padding-top:10px;">`;
|
||
html += `<div style="color:#5bc4ff;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;">Keyframe Icon Legend</div>`;
|
||
html += `<div style="display:flex;gap:18px;flex-wrap:wrap;">`;
|
||
const legendItems = [
|
||
['◆', '#ffd700', 'Linear — constant speed'],
|
||
['●', '#ffd700', 'Smooth (Catmull-Rom / Bezier)'],
|
||
['■', '#ffd700', 'Hold / Constant (instant jump)'],
|
||
['⧫', '#ffd700', 'Ease — slow in & slow out (hourglass)'],
|
||
['◑', '#ffd700', 'Split — different in/out interpolation'],
|
||
];
|
||
for (const [icon, color, desc] of legendItems) {
|
||
html += `<span style="white-space:nowrap;"><span style="color:${color};font-size:14px;">${icon}</span> <span style="color:#8898b8;font-size:11px;">${desc}</span></span>`;
|
||
}
|
||
html += `</div></div>`;
|
||
html += `<div style="text-align:right;margin-top:14px;"><button id="_twHelpClose" style="background:#1d2138;border:1px solid #2c3352;color:#b0bcd4;padding:5px 18px;border-radius:4px;cursor:pointer;font-size:12px;">Close [Escape / F1]</button></div>`;
|
||
panel.innerHTML = html;
|
||
modal.appendChild(panel);
|
||
document.body.appendChild(modal);
|
||
const close = () => { if (modal.parentNode) document.body.removeChild(modal); };
|
||
document.getElementById("_twHelpClose").addEventListener("click", close);
|
||
modal.addEventListener("mousedown", e => { if (e.target === modal) close(); });
|
||
// Close on Escape or F1 — but only this modal's own listener
|
||
const onKey = e => { if (e.key === "Escape" || e.key === "F1") { e.stopPropagation(); close(); document.removeEventListener("keydown", onKey, true); } };
|
||
document.addEventListener("keydown", onKey, true);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Frame navigation
|
||
// -----------------------------------------------------------------------
|
||
_seekFrame(idx) {
|
||
if (this.frameCount === 0) return;
|
||
// Clamp to frame range
|
||
const lo = this.frameRangeStart, hi = this.frameRangeEnd || this.frameCount - 1;
|
||
idx = Math.max(lo, Math.min(hi, idx));
|
||
if (idx !== this.currentFrame) this._logAction("scrub", { from: this.currentFrame, to: idx });
|
||
|
||
// Auto Keyframe OFF: discard temporary edits when leaving the frame
|
||
if (!this.autoKeyframe && this._tempKeys.size > 0 && idx !== this.currentFrame) {
|
||
for (const key of this._tempKeys) {
|
||
const [fStr, label] = key.split("::");
|
||
const fi = parseInt(fStr);
|
||
if (this.overrides[fi]) {
|
||
delete this.overrides[fi][label];
|
||
if (Object.keys(this.overrides[fi]).length === 0) delete this.overrides[fi];
|
||
}
|
||
}
|
||
this._tempKeys.clear();
|
||
}
|
||
|
||
this.currentFrame = idx;
|
||
this.scrubber.value = String(idx);
|
||
this.frameLabel.textContent = `Frame: ${idx} / ${this.frameCount-1}`;
|
||
this._renderFrame(idx);
|
||
if (this._refVideo && this.showReference) this._seekRefVideo(idx);
|
||
this._renderTrack();
|
||
this._updateInterpBtns();
|
||
this._updateDetailPanels();
|
||
this._updateSidebarValues();
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
}
|
||
|
||
_updateDetailPanels() {
|
||
const fi = this.currentFrame;
|
||
const fd = this._getEffectiveFrame(fi);
|
||
if (this._detailInputs && Object.keys(this._detailInputs).length > 0) {
|
||
for (const [label, inputs] of Object.entries(this._detailInputs)) {
|
||
const { group, index } = _splitLabel(label);
|
||
const grpPts = _grpPts(fd, group);
|
||
const pt = grpPts?.[index] ?? [0, 0, 1];
|
||
if (inputs.xInp && document.activeElement !== inputs.xInp) inputs.xInp.value = pt[0].toFixed(1);
|
||
if (inputs.yInp && document.activeElement !== inputs.yInp) inputs.yInp.value = pt[1].toFixed(1);
|
||
if (inputs.confInp && document.activeElement !== inputs.confInp) inputs.confInp.value = ((pt[2] ?? 1) * 100).toFixed(0);
|
||
if (inputs.zInp && document.activeElement !== inputs.zInp) {
|
||
inputs.zInp.value = (pt[3] ?? 0).toFixed(3);
|
||
}
|
||
if (inputs.zOffsetInp && document.activeElement !== inputs.zOffsetInp) {
|
||
inputs.zOffsetInp.value = (this.zGlobalOffset[label] ?? 0).toFixed(3);
|
||
}
|
||
}
|
||
}
|
||
if (this._rotInputs) {
|
||
for (const [label, chs] of Object.entries(this._rotInputs)) {
|
||
for (const [ch, inp] of Object.entries(chs)) {
|
||
if (document.activeElement !== inp) {
|
||
const v = this.overrides[fi]?.[`${label}::${ch}`]
|
||
?? this._interpolateChannel(label, ch, fi) ?? 0;
|
||
inp.value = v.toFixed(1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Viewport rendering
|
||
// -----------------------------------------------------------------------
|
||
_renderFrame(idx) {
|
||
this._preRotZ = null;
|
||
this._moveGizmoPos = null; // reset each frame; front views re-set it if joint selected
|
||
const cw = this.canvas.width, ch = this.canvas.height;
|
||
if (!cw||!ch) return;
|
||
const ctx = this.canvas.getContext("2d");
|
||
ctx.clearRect(0,0,cw,ch); ctx.fillStyle="#08080f"; ctx.fillRect(0,0,cw,ch);
|
||
this.gizmoCenter = null;
|
||
|
||
const panelRects = this._getPanelRects(cw, ch);
|
||
const n = panelRects.length;
|
||
for (let i = 0; i < n; i++) {
|
||
const r = panelRects[i];
|
||
const view = this._panelViews[i] ?? "front";
|
||
const contentH = r.h - PANEL_HEADER_H;
|
||
const contentW = r.w;
|
||
|
||
// Draw panel header
|
||
this._drawPanelHeader(ctx, i, r, view);
|
||
|
||
// Render view content in clipped region
|
||
this._loadPanelState(i);
|
||
const savedCamView = this.cameraView;
|
||
this.cameraView = view; // let _renderFrontView know which view mode is active
|
||
ctx.save();
|
||
ctx.beginPath(); ctx.rect(r.x, r.y + PANEL_HEADER_H, contentW, contentH); ctx.clip();
|
||
ctx.translate(r.x, r.y + PANEL_HEADER_H);
|
||
|
||
switch (view) {
|
||
case "orbit": this._renderOrbitView(ctx, idx, contentW, contentH); break;
|
||
case "top": this._renderOrthoView(ctx, idx, contentW, contentH, "top"); break;
|
||
case "side": this._renderOrthoView(ctx, idx, contentW, contentH, "side"); break;
|
||
case "back": this._renderBackView(ctx, idx, contentW, contentH); break;
|
||
case "camera": this._renderCameraView(ctx, idx, contentW, contentH); break;
|
||
default: this._renderFrontView(ctx, idx, contentW, contentH, 0, 0); break;
|
||
}
|
||
|
||
// Promote gizmoCenter to full canvas coords (was in local content coords)
|
||
if (this.gizmoCenter) {
|
||
this.gizmoCenter.x += r.x;
|
||
this.gizmoCenter.y += r.y + PANEL_HEADER_H;
|
||
}
|
||
ctx.restore();
|
||
this.cameraView = savedCamView;
|
||
}
|
||
|
||
// Restore active-panel state — the loop leaves this.orbitZoom etc. set to panel[n-1]
|
||
if (n > 1) this._loadPanelState(this._activePanel);
|
||
|
||
// Draw panel dividers
|
||
if (n > 1) {
|
||
ctx.strokeStyle = "#2c3352"; ctx.lineWidth = 1;
|
||
const vx = panelRects[1].x;
|
||
ctx.beginPath(); ctx.moveTo(vx, 0); ctx.lineTo(vx, ch); ctx.stroke();
|
||
if (n === 4) {
|
||
const hy = panelRects[2].y;
|
||
ctx.beginPath(); ctx.moveTo(0, hy); ctx.lineTo(cw, hy); ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// Per-panel overlays: frame badge + active-panel accent border
|
||
for (let i = 0; i < n; i++) {
|
||
const r = panelRects[i];
|
||
this._drawFrameBadge(ctx, r, idx);
|
||
if (i === this._activePanel && n > 1) this._drawActivePanelBorder(ctx, r);
|
||
}
|
||
}
|
||
|
||
_drawFrameBadge(ctx, r, idx) {
|
||
const total = Math.max(0, this.frameCount - 1);
|
||
const txt = `F ${idx} / ${total}`;
|
||
ctx.save();
|
||
ctx.font = "bold 10px 'JetBrains Mono',monospace";
|
||
const w = ctx.measureText(txt).width + 12;
|
||
const x = r.x + r.w - w - 6, y = r.y + PANEL_HEADER_H + 6;
|
||
ctx.fillStyle = "rgba(6,8,18,0.78)";
|
||
ctx.fillRect(x, y, w, 16);
|
||
ctx.strokeStyle = "rgba(91,196,255,0.3)"; ctx.lineWidth = 1;
|
||
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, 15);
|
||
ctx.fillStyle = "#7ac8f0";
|
||
ctx.textBaseline = "middle"; ctx.textAlign = "center";
|
||
ctx.fillText(txt, x + w / 2, y + 8);
|
||
ctx.textBaseline = "alphabetic"; ctx.textAlign = "left";
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawActivePanelBorder(ctx, r) {
|
||
ctx.save();
|
||
ctx.strokeStyle = "#5a8cff"; ctx.lineWidth = 2;
|
||
ctx.strokeRect(r.x + 1, r.y + 1, r.w - 2, r.h - 2);
|
||
ctx.restore();
|
||
}
|
||
|
||
_getFrontTransform(vW, vH) {
|
||
const base = Math.min(vW / this.poseW, vH / this.poseH) * 0.95;
|
||
const sx = base * this.vpZoom, sy = sx;
|
||
const ox = (vW - this.poseW * sx) / 2 + this.vpPanX;
|
||
const oy = (vH - this.poseH * sy) / 2 + this.vpPanY;
|
||
return { sx, sy, ox, oy };
|
||
}
|
||
|
||
// ── Grid / gizmo helpers ─────────────────────────────────────────────────
|
||
|
||
/** Draw a 10×10 normalised grid over a 2D view canvas region. */
|
||
_drawViewGrid(ctx, aox, aoy, pW, pH) {
|
||
ctx.save();
|
||
ctx.strokeStyle = "rgba(80,105,150,0.22)";
|
||
ctx.lineWidth = 0.5;
|
||
const DIVS = 10;
|
||
for (let i = 1; i < DIVS; i++) {
|
||
const x = aox + (i / DIVS) * pW;
|
||
ctx.beginPath(); ctx.moveTo(x, aoy); ctx.lineTo(x, aoy + pH); ctx.stroke();
|
||
const y = aoy + (i / DIVS) * pH;
|
||
ctx.beginPath(); ctx.moveTo(aox, y); ctx.lineTo(aox + pW, y); ctx.stroke();
|
||
}
|
||
// Bright centre cross
|
||
ctx.strokeStyle = "rgba(110,140,200,0.38)"; ctx.lineWidth = 1;
|
||
const midX = aox + pW / 2, midY = aoy + pH / 2;
|
||
ctx.beginPath(); ctx.moveTo(midX, aoy); ctx.lineTo(midX, aoy + pH); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(aox, midY); ctx.lineTo(aox + pW, midY); ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
/** Draw XY translation gizmo arrows at canvas position (cx, cy). */
|
||
_drawMoveGizmo(ctx, cx, cy) {
|
||
const LEN = 44, HEAD = 9;
|
||
ctx.save();
|
||
ctx.lineWidth = 2;
|
||
// X axis — red → right
|
||
ctx.strokeStyle = "#ee3333"; ctx.fillStyle = "#ee3333";
|
||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + LEN, cy); ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx + LEN + HEAD, cy);
|
||
ctx.lineTo(cx + LEN, cy - HEAD / 2);
|
||
ctx.lineTo(cx + LEN, cy + HEAD / 2);
|
||
ctx.closePath(); ctx.fill();
|
||
// Y axis — green ↑ up
|
||
ctx.strokeStyle = "#33ee33"; ctx.fillStyle = "#33ee33";
|
||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx, cy - LEN); ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy - LEN - HEAD);
|
||
ctx.lineTo(cx - HEAD / 2, cy - LEN);
|
||
ctx.lineTo(cx + HEAD / 2, cy - LEN);
|
||
ctx.closePath(); ctx.fill();
|
||
// Centre square
|
||
ctx.fillStyle = "#ffffff"; ctx.globalAlpha = 0.7;
|
||
ctx.fillRect(cx - 3, cy - 3, 6, 6);
|
||
ctx.restore();
|
||
}
|
||
|
||
/** Return "x", "y", or null depending on which gizmo arrow the pointer is over. */
|
||
_hitTestMoveGizmo(lx, ly) {
|
||
if (!this._moveGizmoPos || !this.selectedJoint) return null;
|
||
const { cx, cy } = this._moveGizmoPos;
|
||
const LEN = 44, HEAD = 9, RAD = 13;
|
||
if (Math.abs(lx - (cx + LEN + HEAD / 2)) < RAD && Math.abs(ly - cy) < RAD) return "x";
|
||
if (Math.abs(lx - cx) < RAD && Math.abs(ly - (cy - LEN - HEAD / 2)) < RAD) return "y";
|
||
return null;
|
||
}
|
||
|
||
_renderFrontView(ctx, idx, vW, vH, offX, offY) {
|
||
const { sx,sy,ox,oy } = this._getFrontTransform(vW, vH);
|
||
const aox=offX+ox, aoy=offY+oy;
|
||
// Ghost region: fill entire viewport so user can see where the canvas edges are
|
||
if (this.vpZoom > 1.05) {
|
||
ctx.fillStyle="#08080f"; ctx.fillRect(offX, offY, vW, vH);
|
||
}
|
||
|
||
ctx.fillStyle="#0e1020"; ctx.fillRect(aox,aoy,this.poseW*sx,this.poseH*sy);
|
||
const _refSrc = this.showReference ? this._getRefSource(idx) : null;
|
||
if (_refSrc) {
|
||
ctx.globalAlpha = this._refMeta?.opacity ?? 0.55;
|
||
ctx.drawImage(_refSrc, aox, aoy, this.poseW*sx, this.poseH*sy);
|
||
ctx.globalAlpha = 1;
|
||
} else if (this._bgCache[idx]) {
|
||
ctx.globalAlpha = 0.55;
|
||
ctx.drawImage(this._bgCache[idx], aox, aoy, this.poseW*sx, this.poseH*sy);
|
||
ctx.globalAlpha = 1;
|
||
} else {
|
||
this._fetchBg(idx);
|
||
}
|
||
ctx.strokeStyle="#3a3a5a"; ctx.lineWidth=1;
|
||
ctx.strokeRect(aox+0.5,aoy+0.5,this.poseW*sx-1,this.poseH*sy-1);
|
||
this._drawViewGrid(ctx, aox, aoy, this.poseW * sx, this.poseH * sy);
|
||
const dwAlpha = this._experimentalMode ? this._dwposeAlpha : 1;
|
||
if (dwAlpha < 1) { ctx.save(); ctx.globalAlpha = dwAlpha; }
|
||
this._drawSkeleton(ctx, idx, sx, sy, aox, aoy);
|
||
if (dwAlpha < 1) ctx.restore();
|
||
// Move gizmo for selected joint — rendered on top of skeleton
|
||
if (this.selectedJoint) {
|
||
const _fgd = this._getEffectiveFrame(idx);
|
||
const { group: _gg, index: _gi } = this.selectedJoint;
|
||
const _gPts = _grpPts(_fgd, _gg);
|
||
const _gPt = _gPts?.[_gi];
|
||
if (_gPt && (_gPt[2] ?? 1) >= 0.01) {
|
||
const _gc = this._poseToCanvas(_gPt[0], _gPt[1], sx, sy, aox, aoy);
|
||
this._moveGizmoPos = { cx: _gc.x, cy: _gc.y };
|
||
this._drawMoveGizmo(ctx, _gc.x, _gc.y);
|
||
}
|
||
}
|
||
if (this._experimentalMode && this._nlfAlpha > 0 && this._nlfData) {
|
||
this._drawNlfOverlay(ctx, idx, sx, sy, aox, aoy, this._nlfAlpha);
|
||
}
|
||
// Camera motion path + 2D gizmo — shown in front/back/top/side views
|
||
if (this._hasCameraKeyframes()) {
|
||
this._drawCameraPath(ctx, (nx, ny) => ({ x: aox + nx * this.poseW * sx, y: aoy + ny * this.poseH * sy }));
|
||
this._drawCamera2DGizmo(ctx, idx, sx, sy, aox, aoy, offX, offY, vW, vH);
|
||
}
|
||
// Zoom level indicator
|
||
if (this.vpZoom !== 1.0 || this.vpPanX !== 0 || this.vpPanY !== 0) {
|
||
ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(offX+3, offY+vH-18, 130, 14);
|
||
ctx.fillStyle = "#5a7a9a"; ctx.font = "9px 'JetBrains Mono',monospace";
|
||
ctx.fillText(`Zoom: ${this.vpZoom.toFixed(2)}x Dbl-click: reset`, offX+6, offY+vH-7);
|
||
}
|
||
// Viewport rubber-band selection rect
|
||
if (this._vpSelectRect) {
|
||
const {startX,startY,curX,curY}=this._vpSelectRect;
|
||
const rx=Math.min(startX,curX)+offX, ry=Math.min(startY,curY)+offY;
|
||
const rw=Math.abs(curX-startX), rh=Math.abs(curY-startY);
|
||
ctx.fillStyle="rgba(80,160,255,0.1)"; ctx.fillRect(rx,ry,rw,rh);
|
||
ctx.strokeStyle="#5599ff"; ctx.lineWidth=1; ctx.setLineDash([3,2]);
|
||
ctx.strokeRect(rx,ry,rw,rh); ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
_poseToCanvas(px,py,sx,sy,ox,oy) { return { x:ox+px*sx, y:oy+py*sy }; }
|
||
_canvasToPose(cx,cy,sx,sy,ox,oy) { return { x:(cx-ox)/sx, y:(cy-oy)/sy }; }
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Back view — front mirrored on X
|
||
// -----------------------------------------------------------------------
|
||
_renderBackView(ctx, idx, vW, vH) {
|
||
ctx.save();
|
||
ctx.translate(vW, 0); ctx.scale(-1, 1);
|
||
this._renderFrontView(ctx, idx, vW, vH, 0, 0);
|
||
ctx.restore();
|
||
ctx.fillStyle="rgba(0,0,0,0.55)"; ctx.fillRect(2,2,54,14);
|
||
ctx.fillStyle="#5a7a9a"; ctx.font="bold 9px 'JetBrains Mono',monospace"; ctx.fillText("BACK VIEW",5,13);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Camera view — orbit view driven by cam_pan / cam_tilt / cam_z KFs
|
||
// -----------------------------------------------------------------------
|
||
_renderCameraView(ctx, idx, vW, vH) {
|
||
const cam = this._getInterpolatedCamera(idx);
|
||
// Apply live preview (pan/tilt from orbit drag, x/y from shift-drag or aim drag)
|
||
if (this._camLockPreview) {
|
||
if (this._camLockPreview.pan !== undefined) cam.pan = this._camLockPreview.pan;
|
||
if (this._camLockPreview.tilt !== undefined) cam.tilt = this._camLockPreview.tilt;
|
||
if (this._camLockPreview.x !== undefined) cam.x = this._camLockPreview.x;
|
||
if (this._camLockPreview.y !== undefined) cam.y = this._camLockPreview.y;
|
||
}
|
||
const savedYaw = this.orbitYaw, savedPitch = this.orbitPitch, savedZoom = this.orbitZoom;
|
||
this.orbitYaw = cam.pan;
|
||
this.orbitPitch = cam.tilt;
|
||
// FOV affects apparent zoom: 60° = 1×, telephoto (lower FOV) = more zoom
|
||
const fovZoom = Math.tan(30 * Math.PI / 180) / Math.tan(Math.max(1, cam.fov) / 2 * Math.PI / 180);
|
||
this.orbitZoom = Math.max(0.01, cam.z * fovZoom);
|
||
|
||
// Shift orbit center so cam.x/y aim point appears at screen center (like a real camera target)
|
||
const M = this._getOrbitMatrix();
|
||
const orbitScale = Math.min(vW / this.poseW, vH / this.poseH) * 0.82 * this.orbitZoom;
|
||
this._orbitCenterOffset = {
|
||
x: -(M.m00 * cam.x * this.poseW + M.m10 * cam.y * this.poseH) * orbitScale,
|
||
y: -(M.m01 * cam.x * this.poseW + M.m11 * cam.y * this.poseH) * orbitScale,
|
||
};
|
||
const rollRad = cam.roll * Math.PI / 180;
|
||
if (rollRad !== 0) { ctx.save(); ctx.translate(vW/2, vH/2); ctx.rotate(rollRad); ctx.translate(-vW/2, -vH/2); }
|
||
this._renderOrbitView(ctx, idx, vW, vH);
|
||
if (rollRad !== 0) ctx.restore();
|
||
this._orbitCenterOffset = null;
|
||
this.orbitYaw = savedYaw;
|
||
this.orbitPitch = savedPitch;
|
||
this.orbitZoom = savedZoom;
|
||
|
||
// Camera frame border — follows letterbox rect when output aspect is set
|
||
const lb = this._threeOrbit?._lastLetterbox;
|
||
const bx = lb ? lb.x + 1 : 2, by = lb ? lb.y + 1 : 2;
|
||
const bw = lb ? lb.w - 2 : vW - 4, bh = lb ? lb.h - 2 : vH - 4;
|
||
ctx.strokeStyle = "#cc8800"; ctx.lineWidth = 2; ctx.setLineDash([6,3]);
|
||
ctx.strokeRect(bx, by, bw, bh);
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "rgba(0,0,0,0.75)"; ctx.fillRect(bx + 1, by + 1, 108, 14);
|
||
ctx.fillStyle = "#cc8800"; ctx.font = "bold 9px 'JetBrains Mono',monospace";
|
||
ctx.textAlign = "left"; ctx.textBaseline = "top";
|
||
ctx.fillText("◎ CAMERA VIEW", bx + 4, by + 2);
|
||
ctx.textBaseline = "alphabetic";
|
||
|
||
// Lock Camera to View button (anchored to letterbox bottom-left)
|
||
const btnW = 140, btnH = 16, btnX = bx + 1, btnY = by + bh - 19;
|
||
ctx.fillStyle = this._camLocked ? "rgba(20,80,180,0.85)" : "rgba(0,0,0,0.65)";
|
||
ctx.fillRect(btnX, btnY, btnW, btnH);
|
||
ctx.strokeStyle = this._camLocked ? "#44aaff" : "#445";
|
||
ctx.lineWidth = 1; ctx.strokeRect(btnX, btnY, btnW, btnH);
|
||
ctx.fillStyle = this._camLocked ? "#aaddff" : "#88aacc";
|
||
ctx.font = "bold 9px 'JetBrains Mono',monospace"; ctx.textAlign = "left"; ctx.textBaseline = "top";
|
||
ctx.fillText(this._camLocked ? "Lock Cam to View: ON" : "Lock Cam to View: OFF", btnX + 4, btnY + 3);
|
||
ctx.textBaseline = "alphabetic";
|
||
this._camLockBtnRect = { x: btnX, y: btnY, w: btnW, h: btnH };
|
||
|
||
// Ortho / Perspective toggle (right of lock button)
|
||
const orthW = 110, orthH = 16, orthX = btnX + btnW + 2, orthY = btnY;
|
||
ctx.fillStyle = this._camOrtho ? "rgba(20,80,40,0.85)" : "rgba(60,20,20,0.85)";
|
||
ctx.fillRect(orthX, orthY, orthW, orthH);
|
||
ctx.strokeStyle = this._camOrtho ? "#44cc88" : "#cc6644";
|
||
ctx.lineWidth = 1; ctx.strokeRect(orthX, orthY, orthW, orthH);
|
||
ctx.fillStyle = this._camOrtho ? "#88ffcc" : "#ffaa88";
|
||
ctx.font = "bold 9px 'JetBrains Mono',monospace"; ctx.textAlign = "left"; ctx.textBaseline = "top";
|
||
ctx.fillText(this._camOrtho ? "⊡ Orthographic" : "⊙ Perspective", orthX + 4, orthY + 3);
|
||
ctx.textBaseline = "alphabetic";
|
||
this._camOrthoBtnRect = { x: orthX, y: orthY, w: orthW, h: orthH };
|
||
|
||
// Hint when locked
|
||
if (this._camLocked) {
|
||
ctx.fillStyle = "rgba(0,0,0,0.65)"; ctx.fillRect(bx + 1, btnY - 17, 180, 14);
|
||
ctx.fillStyle = "#5a90c0"; ctx.font = "9px 'JetBrains Mono',monospace";
|
||
ctx.textAlign = "left"; ctx.textBaseline = "top";
|
||
ctx.fillText("Drag=orbit Shift+drag=pan Scroll=dolly FOV=dope", bx + 4, btnY - 15);
|
||
ctx.textBaseline = "alphabetic";
|
||
}
|
||
}
|
||
|
||
// Simple 2D camera icon drawn in front / back / top / side views
|
||
_drawCamera2DGizmo(ctx, idx, sx, sy, aox, aoy, offX, offY, vW, vH) {
|
||
const cam = this._getInterpolatedCamera(idx);
|
||
// Aim point in canvas coords (cam.x/y are normalised offsets from centre)
|
||
const aimX = aox + (0.5 + cam.x) * this.poseW * sx;
|
||
const aimY = aoy + (0.5 + cam.y) * this.poseH * sy;
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.75;
|
||
// Crosshair at aim point
|
||
ctx.strokeStyle = "#cc8800"; ctx.lineWidth = 1; ctx.setLineDash([3,2]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(aimX - 10, aimY); ctx.lineTo(aimX + 10, aimY);
|
||
ctx.moveTo(aimX, aimY - 10); ctx.lineTo(aimX, aimY + 10);
|
||
ctx.stroke(); ctx.setLineDash([]);
|
||
// Small circle
|
||
ctx.strokeStyle = "#ffcc44"; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.arc(aimX, aimY, 5, 0, Math.PI * 2); ctx.stroke();
|
||
// Label
|
||
ctx.fillStyle = "#cc8800"; ctx.font = "bold 8px monospace";
|
||
ctx.textAlign = "left"; ctx.textBaseline = "top";
|
||
ctx.fillText("◎ CAM", aimX + 7, aimY - 4);
|
||
ctx.textBaseline = "alphabetic";
|
||
ctx.restore();
|
||
}
|
||
|
||
// Camera flight-path spline drawn in all views except camera view.
|
||
// project2D(normX, normY) → {x, y} in canvas coords, or null to skip point.
|
||
_drawCameraPath(ctx, project2D) {
|
||
const kfFrames = Object.keys(this.overrides)
|
||
.map(Number).sort((a, b) => a - b)
|
||
.filter(fi => "cam_x" in this.overrides[fi] || "cam_pan" in this.overrides[fi]);
|
||
if (kfFrames.length < 2) return;
|
||
|
||
// Sample the interpolated path at ~20 steps per segment
|
||
const STEPS = 20;
|
||
const span = kfFrames[kfFrames.length - 1] - kfFrames[0];
|
||
const step = Math.max(1, span / (kfFrames.length * STEPS));
|
||
const pts = [];
|
||
for (let fi = kfFrames[0]; fi <= kfFrames[kfFrames.length - 1] + 0.5; fi += step) {
|
||
const cam = this._getInterpolatedCamera(Math.round(Math.min(fi, kfFrames[kfFrames.length - 1])));
|
||
const p = project2D(0.5 + cam.x, 0.5 + cam.y);
|
||
if (p) pts.push(p);
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.strokeStyle = "rgba(255,160,40,0.55)";
|
||
ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([4, 3]);
|
||
ctx.beginPath();
|
||
pts.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
|
||
ctx.stroke();
|
||
|
||
// Dots at actual KF positions
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = "#ffaa28";
|
||
kfFrames.forEach(fi => {
|
||
const cam = this._getInterpolatedCamera(fi);
|
||
const p = project2D(0.5 + cam.x, 0.5 + cam.y);
|
||
if (!p) return;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); ctx.fill();
|
||
});
|
||
ctx.restore();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Top / Side orthographic views
|
||
// -----------------------------------------------------------------------
|
||
_renderTopView(ctx, idx, vW, vH) {
|
||
this._renderOrthoView(ctx, idx, vW, vH, "top");
|
||
}
|
||
_renderSideView(ctx, idx, vW, vH) {
|
||
this._renderOrthoView(ctx, idx, vW, vH, "side");
|
||
}
|
||
|
||
_renderOrthoView(ctx, idx, vW, vH, axis) {
|
||
ctx.fillStyle = "#0d0d1a"; ctx.fillRect(0, 0, vW, vH);
|
||
|
||
const fd = this._getEffectiveFrame(idx); if (!fd) return;
|
||
if (ROTATION_ENABLED) this._applyWristRotations(fd, idx);
|
||
const body = fd.body || [];
|
||
|
||
// Zoom/pan from panel state (loaded by _renderFrame before calling this)
|
||
const MARGIN = 0.10;
|
||
const scale = Math.min(vW, vH) * (1 - MARGIN * 2) * this.vpZoom;
|
||
const cx = vW / 2 + this.vpPanX;
|
||
const cy = vH / 2 + this.vpPanY;
|
||
const Z_RANGE = 1.5;
|
||
|
||
const pW = this.poseW, pH = this.poseH;
|
||
const toScreen = (jx, jy, jz) => {
|
||
if (axis === "top") {
|
||
return { x: cx + (jx / pW - 0.5) * scale, y: cy + jz / Z_RANGE * (scale * 0.5) };
|
||
} else {
|
||
return { x: cx - jz / Z_RANGE * (scale * 0.5), y: cy + (jy / pH - 0.5) * scale };
|
||
}
|
||
};
|
||
|
||
// Grid lines
|
||
ctx.strokeStyle = "#1e2a3a"; ctx.lineWidth = 1;
|
||
for (let t = -1; t <= 1; t += 0.25) {
|
||
const off = t / Z_RANGE * (scale * 0.5);
|
||
if (axis === "top") {
|
||
ctx.beginPath(); ctx.moveTo(0, cy + off); ctx.lineTo(vW, cy + off); ctx.stroke();
|
||
} else {
|
||
ctx.beginPath(); ctx.moveTo(cx + off, 0); ctx.lineTo(cx + off, vH); ctx.stroke();
|
||
}
|
||
}
|
||
ctx.strokeStyle = "#2a3a5a"; ctx.lineWidth = 1.5;
|
||
if (axis === "top") { ctx.beginPath(); ctx.moveTo(0,cy); ctx.lineTo(vW,cy); ctx.stroke(); }
|
||
else { ctx.beginPath(); ctx.moveTo(cx,0); ctx.lineTo(cx,vH); ctx.stroke(); }
|
||
|
||
// DWPose body bones
|
||
const bodyGrpVis = !this.hiddenGroups.has("body");
|
||
if (bodyGrpVis) {
|
||
for (let i = 0; i < BODY_CONNECTIONS.length; i++) {
|
||
const [a, b] = BODY_CONNECTIONS[i];
|
||
if (!body[a] || !body[b]) continue;
|
||
const boneAlpha = this._showAll ? 1 : Math.min(body[a][2]??1, body[b][2]??1);
|
||
if (boneAlpha < 0.01) continue;
|
||
const za = body[a][3] ?? 0;
|
||
const zb = body[b][3] ?? 0;
|
||
const pa = toScreen(body[a][0], body[a][1], za);
|
||
const pb = toScreen(body[b][0], body[b][1], zb);
|
||
ctx.globalAlpha = boneAlpha;
|
||
ctx.strokeStyle = BONE_COLORS[i] || "#aaa"; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
for (let i = 0; i < body.length; i++) {
|
||
const pt = body[i]; if (!pt) continue;
|
||
const alpha = this._showAll ? 1 : (pt[2]??1);
|
||
if (alpha < 0.01) continue;
|
||
const label = `body_${i}`;
|
||
if (this.hiddenLayers.has(label)) continue;
|
||
const p = toScreen(pt[0], pt[1], pt[3] ?? 0);
|
||
ctx.globalAlpha = alpha;
|
||
const isSelected = this.selectedJoints.has(label);
|
||
ctx.fillStyle = JOINT_COLORS[i] || "#fff";
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, isSelected ? 6 : 4, 0, Math.PI*2); ctx.fill();
|
||
if (isSelected) {
|
||
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI*2); ctx.stroke();
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
// DWPose hands in ortho view (use wrist Z for all hand joints)
|
||
for (const [side, kps, wristIdx] of [["rhand",fd.rhand,R_WRIST],["lhand",fd.lhand,L_WRIST]]) {
|
||
if (!kps || this.hiddenGroups.has(side)) continue;
|
||
const wristZ = body[wristIdx]?.[3] ?? 0;
|
||
ctx.strokeStyle = side === "rhand" ? "#8844ff" : "#44aaff"; ctx.lineWidth = 1;
|
||
for (const [a, b] of HAND_CONNECTIONS) {
|
||
if (!kps[a] || !kps[b]) continue;
|
||
const pa = toScreen(kps[a][0], kps[a][1], wristZ);
|
||
const pb = toScreen(kps[b][0], kps[b][1], wristZ);
|
||
ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.stroke();
|
||
}
|
||
ctx.fillStyle = side === "rhand" ? "#aa66ff" : "#66ccff";
|
||
for (let i = 0; i < kps.length; i++) {
|
||
if (!kps[i]) continue;
|
||
const p = toScreen(kps[i][0], kps[i][1], wristZ);
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, Math.PI*2); ctx.fill();
|
||
}
|
||
}
|
||
|
||
// Face landmarks — anchor Z to nose (body[0]) when per-point Z is unset
|
||
if (fd.face && !this.hiddenGroups.has("face")) {
|
||
const headZ = body[0]?.[3] ?? 0;
|
||
ctx.fillStyle = FACE_COLOR;
|
||
for (let i = 0; i < fd.face.length; i++) {
|
||
const pt = fd.face[i]; if (!pt) continue;
|
||
const conf = this._showAll ? 1 : (pt[2] ?? 1);
|
||
if (conf < 0.01) continue;
|
||
const label = `face_${i}`;
|
||
if (this.hiddenLayers.has(label)) continue;
|
||
const p = toScreen(pt[0], pt[1], pt[3] ?? headZ);
|
||
ctx.globalAlpha = conf;
|
||
const isSel = this.selectedJoints.has(label);
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, isSel ? 3.5 : 1.8, 0, Math.PI*2); ctx.fill();
|
||
if (isSel) {
|
||
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, 5.5, 0, Math.PI*2); ctx.stroke();
|
||
}
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
// NLF overlay in ortho — use effective nlf_body so edits show up
|
||
if (this._experimentalMode && this._nlfAlpha > 0 && this._nlfData) {
|
||
const efd = this._getEffectiveFrame(idx);
|
||
const nlf = efd?.nlf_body;
|
||
if (nlf?.length) {
|
||
const valid = (pt) => pt && isFinite(pt[0]) && isFinite(pt[1]) && (pt[2] ?? 0) > 0.05;
|
||
ctx.save(); ctx.globalAlpha = this._nlfAlpha * 0.85;
|
||
ctx.strokeStyle = NLF_BODY_COLOR; ctx.lineWidth = 1.6;
|
||
for (let i = 0; i < BODY_CONNECTIONS.length; i++) {
|
||
const [a, b] = BODY_CONNECTIONS[i];
|
||
if (a >= nlf.length || b >= nlf.length) continue;
|
||
if (!valid(nlf[a]) || !valid(nlf[b])) continue;
|
||
const sa = toScreen(nlf[a][0], nlf[a][1], nlf[a][3] ?? 0);
|
||
const sb = toScreen(nlf[b][0], nlf[b][1], nlf[b][3] ?? 0);
|
||
ctx.beginPath(); ctx.moveTo(sa.x, sa.y); ctx.lineTo(sb.x, sb.y); ctx.stroke();
|
||
}
|
||
ctx.fillStyle = NLF_BODY_COLOR;
|
||
const ovr = this.overrides[idx] || {};
|
||
const sel = this.selectedNlfJoint;
|
||
for (let i = 0; i < nlf.length; i++) {
|
||
const pt = nlf[i]; if (!valid(pt)) continue;
|
||
const p = toScreen(pt[0], pt[1], pt[3] ?? 0);
|
||
const isKf = ovr[`nlf_body_${i}`] !== undefined;
|
||
const isSel = sel && sel.group === "nlf_body" && sel.index === i;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, isSel ? 4.5 : (isKf ? 3.5 : 2.5), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
if (isSel) {
|
||
ctx.strokeStyle = "#ffd54a"; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.strokeStyle = NLF_BODY_COLOR;
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// Camera motion path + gizmo in ortho views
|
||
if (this._hasCameraKeyframes()) {
|
||
this._drawCameraPath(ctx, (nx, ny) => toScreen(nx, ny, 0));
|
||
const cam = this._getInterpolatedCamera(idx);
|
||
// Camera body sits in front of the scene (positive Z = closer in ortho view)
|
||
const camDepth = +Math.min(1.0, cam.z * 0.65);
|
||
// Camera body position depends on which axes this view shows
|
||
const camBodyPt = axis === "top"
|
||
? toScreen(0.5 + cam.x, 0.5, camDepth) // top: X + Z-depth
|
||
: toScreen(0.5, 0.5 + cam.y, camDepth); // side: Y + Z-depth
|
||
const camAimPt = toScreen(0.5 + cam.x, 0.5 + cam.y, 0);
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.8;
|
||
// Line from camera body to aim point
|
||
ctx.strokeStyle = "#cc8800"; ctx.lineWidth = 1; ctx.setLineDash([3, 2]);
|
||
ctx.beginPath(); ctx.moveTo(camBodyPt.x, camBodyPt.y); ctx.lineTo(camAimPt.x, camAimPt.y); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
// Aim-point crosshair
|
||
ctx.strokeStyle = "#cc8800"; ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(camAimPt.x - 8, camAimPt.y); ctx.lineTo(camAimPt.x + 8, camAimPt.y);
|
||
ctx.moveTo(camAimPt.x, camAimPt.y - 8); ctx.lineTo(camAimPt.x, camAimPt.y + 8);
|
||
ctx.stroke();
|
||
// Camera body icon
|
||
ctx.strokeStyle = "#ffcc44"; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(camBodyPt.x, camBodyPt.y, 5, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.fillStyle = "#ffcc44"; ctx.font = "bold 8px monospace";
|
||
ctx.textAlign = "left"; ctx.textBaseline = "top";
|
||
ctx.fillText("◎ CAM", camBodyPt.x + 7, camBodyPt.y - 4);
|
||
ctx.textBaseline = "alphabetic"; ctx.textAlign = "left";
|
||
ctx.restore();
|
||
}
|
||
|
||
// Axis labels
|
||
ctx.fillStyle = "#3a5a7a"; ctx.font = "10px 'JetBrains Mono',monospace";
|
||
if (axis === "top") {
|
||
ctx.fillText("→ X", vW - 22, cy - 4);
|
||
ctx.fillText("↓ Z", cx + 4, 18);
|
||
} else {
|
||
ctx.fillText("← Z →", cx - 18, 14);
|
||
ctx.fillText("↓ Y", 4, cy + 12);
|
||
}
|
||
// Selected-joint gizmo — use the same toScreen projection so it sits on the joint
|
||
if (this.selectedJoint) {
|
||
const sj = this.selectedJoint;
|
||
const gPts = _grpPts(fd, sj.group);
|
||
const pt = gPts?.[sj.index];
|
||
if (pt && (pt[2] ?? 1) >= 0.01) {
|
||
const headZ = (body[0]?.[3] ?? 0);
|
||
const fallbackZ = sj.group === "face" ? headZ
|
||
: sj.group === "rhand" ? (body[R_WRIST]?.[3] ?? 0)
|
||
: sj.group === "lhand" ? (body[L_WRIST]?.[3] ?? 0)
|
||
: 0;
|
||
const z = pt[3] ?? fallbackZ;
|
||
const p = toScreen(pt[0], pt[1], z);
|
||
this._drawMoveGizmo(ctx, p.x, p.y);
|
||
}
|
||
}
|
||
|
||
// Zoom hint
|
||
ctx.fillStyle = "#334"; ctx.font = "10px sans-serif";
|
||
ctx.fillText("Scroll=zoom · Alt+drag=pan · Click joint=edit", 6, vH - 6);
|
||
}
|
||
|
||
_applyWristRotations(fd, idx) {
|
||
// Z-axis rotation only — XY coords are rotated, z depth is unchanged.
|
||
if (!this._preRotZ) this._preRotZ = {};
|
||
|
||
const applyRot = (wristLabel, side) => {
|
||
// Only Z-axis rotation active; Rx/Ry kept for future use
|
||
const rz=(this.overrides[idx]?.[`${wristLabel}::rz`]??this._interpolateChannel(wristLabel,"rz",idx)??0)*Math.PI/180;
|
||
if (!rz) return;
|
||
const handPts=side==="rhand"?fd.rhand:fd.lhand;
|
||
if (!handPts?.length) return;
|
||
const pX=handPts[0][0], pY=handPts[0][1];
|
||
const cz=Math.cos(rz), sz2=Math.sin(rz);
|
||
for (let i=0;i<handPts.length;i++) {
|
||
const dx=handPts[i][0]-pX, dy=handPts[i][1]-pY;
|
||
handPts[i][0]=pX+cz*dx-sz2*dy;
|
||
handPts[i][1]=pY+sz2*dx+cz*dy;
|
||
}
|
||
};
|
||
applyRot("body_4","rhand");
|
||
applyRot("body_7","lhand");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Camera system
|
||
// -----------------------------------------------------------------------
|
||
_getInterpolatedCamera(fi) {
|
||
const get = (label, def) => {
|
||
// Exact frame takes priority — _interpolateJoint searches fi-1/fi+1, missing the exact KF
|
||
if (this.overrides[fi]?.[label] !== undefined) return this.overrides[fi][label][0];
|
||
const v = this._interpolateJoint(label, fi);
|
||
return v ? v[0] : def;
|
||
};
|
||
return {
|
||
x: get("cam_x", 0),
|
||
y: get("cam_y", 0),
|
||
z: get("cam_z", 1.0),
|
||
roll: get("cam_roll", 0),
|
||
tilt: get("cam_tilt", 0),
|
||
pan: get("cam_pan", 0),
|
||
fov: get("cam_fov", 60),
|
||
};
|
||
}
|
||
|
||
_hasCameraKeyframes() {
|
||
for (const ov of Object.values(this.overrides))
|
||
if ("cam_x" in ov || "cam_y" in ov || "cam_z" in ov ||
|
||
"cam_roll" in ov || "cam_tilt" in ov || "cam_pan" in ov || "cam_fov" in ov) return true;
|
||
return false;
|
||
}
|
||
|
||
/** Called by "+ Camera" in Add menu — creates default KFs at frame 0 and opens dope sheet. */
|
||
_addCamera() {
|
||
if (!this.overrides[0]) this.overrides[0] = {};
|
||
const ov = this.overrides[0];
|
||
// cam_z = 1/0.82 ≈ 1.220 → orbitZoom = 1.220, scale = 0.82 * 1.220 = 1.0
|
||
// → orthographic camera view fills panel exactly like the front view.
|
||
const defCamZ = +(1.0 / 0.82).toFixed(3); // 1.220
|
||
if (!("cam_x" in ov)) ov["cam_x"] = [0, 0, 1, 0];
|
||
if (!("cam_y" in ov)) ov["cam_y"] = [0, 0, 1, 0];
|
||
if (!("cam_z" in ov)) ov["cam_z"] = [defCamZ, 0, 1, 0];
|
||
if (!("cam_roll" in ov)) ov["cam_roll"] = [0, 0, 1, 0];
|
||
if (!("cam_tilt" in ov)) ov["cam_tilt"] = [0, 0, 1, 0];
|
||
if (!("cam_pan" in ov)) ov["cam_pan"] = [0, 0, 1, 0];
|
||
if (!("cam_fov" in ov)) ov["cam_fov"] = [60, 0, 1, 0];
|
||
this._setCameraView("camera");
|
||
this._switchTab("dope");
|
||
this.expandedGroups.add("camera");
|
||
this._refreshTimeline();
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// NLF / Experimental
|
||
// -----------------------------------------------------------------------
|
||
_toggleExperimental() {
|
||
this._experimentalMode = !this._experimentalMode;
|
||
this._expBtn.textContent = `⬡ Turn to 3D: ${this._experimentalMode ? "ON" : "OFF"}`;
|
||
this._expBtn.style.background = this._experimentalMode ? "#1a2a3a" : "#1a1a2a";
|
||
this._nlfPanel.style.display = this._experimentalMode ? "block" : "none";
|
||
if (this._experimentalMode && this._nlfData === null && this._nlfStatus === "idle") {
|
||
this._fetchNlfData();
|
||
}
|
||
// Auto-swap to 4-viewport layout with camera replacing top when 3D is on.
|
||
if (this._experimentalMode) {
|
||
this._preExp3dLayout = {
|
||
panelLayout: this._panelLayout,
|
||
panelViews: [...this._panelViews],
|
||
activePanel: this._activePanel,
|
||
};
|
||
this._panelViews = ["front", "orbit", "camera", "side"];
|
||
this._setLayout(4);
|
||
} else if (this._preExp3dLayout) {
|
||
this._panelViews = [...this._preExp3dLayout.panelViews];
|
||
this._activePanel = this._preExp3dLayout.activePanel;
|
||
this._setLayout(this._preExp3dLayout.panelLayout);
|
||
this._preExp3dLayout = null;
|
||
} else {
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
}
|
||
|
||
async _fetchNlfData() {
|
||
this._nlfStatus = "loading";
|
||
if (this._nlfStatusEl) this._nlfStatusEl.textContent = "NLF: loading...";
|
||
try {
|
||
const resp = await fetch(`/temporal-editor/nlf/${this.nodeId}`);
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
if (data.available && data.frames?.length) {
|
||
this._nlfData = data.frames;
|
||
this._nlfStatus = "ok";
|
||
const n = data.frames.filter(f => f.body?.length).length;
|
||
const src = data.source === "extractor" ? " (from extractor)" : "";
|
||
if (this._nlfStatusEl)
|
||
this._nlfStatusEl.textContent = `NLF: ${n}/${data.frames.length} frames detected ✓${src}`;
|
||
// Bake every detected NLF frame as an override so the dopesheet shows a keyframe
|
||
// diamond per frame (mirrors the DWpose extractor's per-frame baking).
|
||
// NLF Z is in metres (e.g. 2.5 — 5 m) — normalize per-frame to editor units
|
||
// [~ -0.5, +0.5] by mean-centering the body's depth values then scaling. The
|
||
// Three.js orbit renderer multiplies z by poseW*0.35; without normalization the
|
||
// skeleton flies offscreen.
|
||
// Bake each detected NLF frame as keyframes. Per-frame DYNAMIC normalization:
|
||
// mean-center then divide by that frame's max-abs-deviation × 2 → output range
|
||
// is exactly [-0.5, +0.5] for every frame. This preserves the body's 3D depth
|
||
// shape regardless of absolute distance from camera (matches the old read-only
|
||
// overlay behavior and stays editable). Z is negated so closer-to-camera = +Z.
|
||
let baked = 0;
|
||
for (let fi = 0; fi < data.frames.length; fi++) {
|
||
const op18 = data.frames[fi]?.body_op18;
|
||
if (!op18) continue;
|
||
const zVals = [];
|
||
for (const p of op18) {
|
||
if (p && (p[2] ?? 0) >= 0.05 && isFinite(p[3])) zVals.push(p[3]);
|
||
}
|
||
if (!zVals.length) continue;
|
||
const zMean = zVals.reduce((a, b) => a + b, 0) / zVals.length;
|
||
const zMaxAbs = Math.max(...zVals.map(z => Math.abs(z - zMean)), 0.001);
|
||
const zNorm = (z) => (zMean - z) / zMaxAbs * 0.5;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
for (let i = 0; i < op18.length; i++) {
|
||
const lbl = `nlf_body_${i}`;
|
||
const p = op18[i];
|
||
if (!p || (p[2] ?? 0) < 0.05) continue;
|
||
const zEditor = isFinite(p[3]) ? zNorm(p[3]) : null;
|
||
const existing = this.overrides[fi][lbl];
|
||
if (existing) {
|
||
existing[3] = zEditor;
|
||
} else {
|
||
this.overrides[fi][lbl] = [p[0], p[1], p[2] ?? 1, zEditor];
|
||
baked++;
|
||
}
|
||
}
|
||
}
|
||
if (baked > 0 || data.frames.length > 0) this._refreshTimeline();
|
||
} else {
|
||
this._nlfStatus = "unavailable";
|
||
const reason = data.reason ||
|
||
"connect NLF Model Loader → Magos DWP Extractor and re-run workflow";
|
||
if (this._nlfStatusEl)
|
||
this._nlfStatusEl.textContent = `NLF: ${reason}`;
|
||
}
|
||
} catch (e) {
|
||
this._nlfStatus = "unavailable";
|
||
if (this._nlfStatusEl) this._nlfStatusEl.textContent = `NLF: ${e.message}`;
|
||
}
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
async _applyNlfToDwpose() {
|
||
if (this._nlfStatus !== "ok" || !this._nlfData) {
|
||
alert("No NLF data loaded. Enable Experimental, select an NLF model on the Extractor's nlf_model dropdown and re-run the workflow, then try again.");
|
||
return;
|
||
}
|
||
this._logAction("bake_nlf_z_depth", { nlf_frames: this._nlfData?.length });
|
||
if (this._nlfApplyBtn) this._nlfApplyBtn.textContent = "⏳ Baking Z…";
|
||
try {
|
||
const resp = await fetch(`/temporal-editor/nlf/apply/${this.nodeId}`);
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
const data = await resp.json();
|
||
if (!data.available || !data.frames?.length) {
|
||
alert("NLF apply data not available. Re-run the workflow first.");
|
||
return;
|
||
}
|
||
|
||
this._armUndo();
|
||
const frames = data.frames;
|
||
|
||
// ── Pass 1: collect per-frame stats and find global Z range ──────
|
||
// Orbit view uses: orbit_px = z_editor * (poseW * 0.35) * viewScale
|
||
// Target: max z_editor = 0.5 → max orbit_px ≈ poseW*0.175 ≈ 90px
|
||
// This keeps depth variation clearly visible without blowing out the view.
|
||
const TARGET_HALF_RANGE = 0.5; // ± editor units
|
||
|
||
const perFrame = [];
|
||
let globalMaxAbsZ = 0;
|
||
for (let fi = 0; fi < frames.length; fi++) {
|
||
const op18 = frames[fi]?.body_op18;
|
||
if (!op18?.length) { perFrame.push(null); continue; }
|
||
const zVals = op18.filter(j => j[2] > 0).map(j => j[3]);
|
||
if (!zVals.length) { perFrame.push(null); continue; }
|
||
const zMean = zVals.reduce((a, b) => a + b, 0) / zVals.length;
|
||
const maxAbs = Math.max(...zVals.map(z => Math.abs(z - zMean)));
|
||
if (maxAbs > globalMaxAbsZ) globalMaxAbsZ = maxAbs;
|
||
perFrame.push({ op18, zMean });
|
||
}
|
||
|
||
// Scale factor: maps the globally largest Z deviation to TARGET_HALF_RANGE
|
||
const zScale = globalMaxAbsZ > 0.001 ? TARGET_HALF_RANGE / globalMaxAbsZ : 1;
|
||
|
||
// ── Pass 2: write normalised Z depths ─────────────────────────────
|
||
// Only update Z on EXISTING user overrides — never recreate joints the
|
||
// user explicitly cleaned/deleted. The extractor pre-bakes overrides for
|
||
// every detected joint, so a missing override means the user removed it
|
||
// intentionally and we must respect that.
|
||
let appliedFrames = 0;
|
||
let skippedJoints = 0;
|
||
for (let fi = 0; fi < perFrame.length; fi++) {
|
||
const pf = perFrame[fi];
|
||
if (!pf) continue;
|
||
const { op18, zMean } = pf;
|
||
|
||
const frameOv = this.overrides[fi];
|
||
if (!frameOv) continue; // entire frame was cleaned — leave it alone
|
||
|
||
for (let ji = 0; ji < op18.length; ji++) {
|
||
const [, , conf, z_m] = op18[ji];
|
||
if (conf < 0.01) continue; // unmapped joint (eyes/ears)
|
||
const label = `body_${ji}`;
|
||
const z_editor = (zMean - z_m) * zScale;
|
||
if (frameOv[label]) {
|
||
frameOv[label][3] = z_editor;
|
||
} else {
|
||
skippedJoints++; // user removed this joint — preserve removal
|
||
}
|
||
}
|
||
appliedFrames++;
|
||
}
|
||
|
||
this._lazyPushUndo();
|
||
if (this._nlfStatusEl) {
|
||
const skipMsg = skippedJoints > 0 ? ` (${skippedJoints} cleaned joints preserved)` : "";
|
||
this._nlfStatusEl.textContent = `Z baked: ${appliedFrames} frames ✓${skipMsg}`;
|
||
}
|
||
this._renderFrame(this.currentFrame);
|
||
} catch (e) {
|
||
alert(`Bake Z Depth failed: ${e.message}`);
|
||
} finally {
|
||
if (this._nlfApplyBtn) this._nlfApplyBtn.textContent = "⬇ Bake Z Depth";
|
||
}
|
||
}
|
||
|
||
_syncNlfJoint(fi, label, newX, newY) {
|
||
if (!this._experimentalMode || !this._nlfData?.[fi]?.body) return;
|
||
if (!label.startsWith("body_")) return;
|
||
const ji = parseInt(label.split("_")[1]);
|
||
const smplIdx = OP18_TO_SMPL[ji];
|
||
if (smplIdx === undefined) return;
|
||
const nlfPt = this._nlfData[fi].body[smplIdx];
|
||
if (!nlfPt) return;
|
||
nlfPt[0] = newX / this.poseW;
|
||
nlfPt[1] = newY / this.poseH;
|
||
}
|
||
|
||
_unbakeZDepth() {
|
||
this._pushUndo();
|
||
let count = 0;
|
||
for (const fi of Object.keys(this.overrides)) {
|
||
const ov = this.overrides[fi];
|
||
for (const label of Object.keys(ov)) {
|
||
if (!label.startsWith("body_")) continue;
|
||
if (Array.isArray(ov[label]) && ov[label].length >= 4) {
|
||
ov[label][3] = null;
|
||
count++;
|
||
}
|
||
}
|
||
}
|
||
if (this._nlfStatusEl) this._nlfStatusEl.textContent = `Unbaked: ${count} joints flattened ✓`;
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
// Draw a single NLF bone as a gradient-shaded tube (cylinder illusion)
|
||
_drawNlfTube(ctx, x1, y1, x2, y2, r = 5) {
|
||
const dx = x2 - x1, dy = y2 - y1;
|
||
const len = Math.sqrt(dx * dx + dy * dy);
|
||
if (len < 0.5) return;
|
||
const nx = -dy / len, ny = dx / len; // perpendicular (surface normal direction)
|
||
const mx = (x1 + x2) * 0.5, my = (y1 + y2) * 0.5;
|
||
const g = ctx.createLinearGradient(
|
||
mx + nx * r, my + ny * r,
|
||
mx - nx * r, my - ny * r
|
||
);
|
||
g.addColorStop(0, "rgba(50,0,110,0.75)");
|
||
g.addColorStop(0.22, "rgba(140,50,240,1.0)");
|
||
g.addColorStop(0.5, "rgba(230,160,255,1.0)");
|
||
g.addColorStop(0.78, "rgba(140,50,240,1.0)");
|
||
g.addColorStop(1, "rgba(50,0,110,0.75)");
|
||
ctx.strokeStyle = g;
|
||
ctx.lineWidth = r * 2;
|
||
ctx.lineCap = "round";
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, y1);
|
||
ctx.lineTo(x2, y2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Draw a single NLF joint as a lit sphere
|
||
_drawNlfSphere(ctx, x, y, r = 5) {
|
||
const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.08, x, y, r);
|
||
g.addColorStop(0, "#ffffff");
|
||
g.addColorStop(0.35,"#e0a0ff");
|
||
g.addColorStop(1, "#4a0088");
|
||
ctx.fillStyle = g;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
_drawNlfOverlay(ctx, frameIdx, sx, sy, ox, oy, alpha) {
|
||
// Read EFFECTIVE NLF data (per-frame raw + user overrides + interpolation).
|
||
// Drawn as an 18-joint OpenPose-style skeleton so user edits show up directly.
|
||
const fd = this._getEffectiveFrame(frameIdx);
|
||
const nlf = fd?.nlf_body;
|
||
if (!nlf || !nlf.length) return;
|
||
ctx.save();
|
||
ctx.globalAlpha = alpha;
|
||
const valid = (pt) => pt && isFinite(pt[0]) && isFinite(pt[1]) && (pt[2] ?? 0) > 0.05;
|
||
const toC = (pt) => this._poseToCanvas(pt[0], pt[1], sx, sy, ox, oy);
|
||
// Bones — reuse DWpose body topology (op18 has the same indexing 0..17)
|
||
for (let i = 0; i < BODY_CONNECTIONS.length; i++) {
|
||
const [a, b] = BODY_CONNECTIONS[i];
|
||
if (a >= nlf.length || b >= nlf.length) continue;
|
||
if (!valid(nlf[a]) || !valid(nlf[b])) continue;
|
||
const pa = toC(nlf[a]), pb = toC(nlf[b]);
|
||
this._drawNlfTube(ctx, pa.x, pa.y, pb.x, pb.y, 4);
|
||
}
|
||
// Joint spheres + per-joint state (keyframe / interpolated)
|
||
const ovr = this.overrides[frameIdx] || {};
|
||
const sel = this.selectedNlfJoint;
|
||
for (let i = 0; i < nlf.length; i++) {
|
||
const pt = nlf[i]; if (!valid(pt)) continue;
|
||
const c = toC(pt);
|
||
const lbl = `nlf_body_${i}`;
|
||
const isKf = ovr[lbl] !== undefined;
|
||
const isSelected = sel && sel.group === "nlf_body" && sel.index === i;
|
||
if (isSelected) {
|
||
ctx.globalAlpha = 1;
|
||
this._drawNlfSphere(ctx, c.x, c.y, 6);
|
||
ctx.strokeStyle = "#ffd54a"; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.arc(c.x, c.y, 10, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.globalAlpha = alpha;
|
||
} else if (isKf) {
|
||
this._drawNlfSphere(ctx, c.x, c.y, 5);
|
||
ctx.save(); ctx.globalAlpha = 1;
|
||
ctx.fillStyle = "#ffd700"; ctx.translate(c.x, c.y); ctx.rotate(Math.PI / 4);
|
||
ctx.fillRect(-3, -3, 6, 6); ctx.restore();
|
||
} else {
|
||
this._drawNlfSphere(ctx, c.x, c.y, 4);
|
||
}
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawSkeleton(ctx, idx, sx, sy, ox, oy) {
|
||
const fd = this._getEffectiveFrame(idx); if (!fd) return;
|
||
if (ROTATION_ENABLED) this._applyWristRotations(fd, idx);
|
||
const body=fd.body||[], rhand=fd.rhand, lhand=fd.lhand;
|
||
const ovr=this.overrides[idx]||{};
|
||
const isDisabled = (g,ki) => { const ov=ovr[`${g}_${ki}`]; return Array.isArray(ov)&&ov[2]===0; };
|
||
const isKeyed = (l) => ovr[l]!==undefined;
|
||
const isInterp = (l) => !ovr[l] && this._interpolateJoint(l,idx)!==null;
|
||
const isVis = (label, group) => !this.hiddenGroups.has(group) && !this.hiddenLayers.has(label);
|
||
const bodyVis = !this.hiddenGroups.has("body");
|
||
|
||
const baseAlpha = ctx.globalAlpha; // inherit outer context (e.g. NLF blend)
|
||
const showAll = this._showAll;
|
||
for (let i=0;i<BODY_CONNECTIONS.length;i++) {
|
||
const [a,b]=BODY_CONNECTIONS[i];
|
||
if (!body[a]||!body[b]) continue;
|
||
const boneAlpha = showAll ? 1 : Math.min(body[a][2]??1, body[b][2]??1);
|
||
if (boneAlpha < 0.01) continue;
|
||
if (isDisabled("body",a)||isDisabled("body",b)) continue;
|
||
if (!bodyVis || !isVis(`body_${a}`,"body") || !isVis(`body_${b}`,"body")) continue;
|
||
const pa=this._poseToCanvas(body[a][0],body[a][1],sx,sy,ox,oy);
|
||
const pb=this._poseToCanvas(body[b][0],body[b][1],sx,sy,ox,oy);
|
||
ctx.globalAlpha = baseAlpha * boneAlpha;
|
||
ctx.strokeStyle=BONE_COLORS[i]||"#aaa"; ctx.lineWidth=2;
|
||
ctx.beginPath(); ctx.moveTo(pa.x,pa.y); ctx.lineTo(pb.x,pb.y); ctx.stroke();
|
||
}
|
||
for (let i=0;i<body.length;i++) {
|
||
const pt=body[i]; if (!pt) continue;
|
||
const conf = showAll ? 1 : (pt[2]??1);
|
||
if (conf < 0.01) continue;
|
||
const label=`body_${i}`;
|
||
if (!isVis(label,"body")) continue;
|
||
if (isDisabled("body",i)) {
|
||
ctx.globalAlpha = baseAlpha;
|
||
const c=this._poseToCanvas(pt[0],pt[1],sx,sy,ox,oy);
|
||
ctx.strokeStyle="#ff2222"; ctx.lineWidth=2; const r=6;
|
||
ctx.beginPath(); ctx.moveTo(c.x-r,c.y-r); ctx.lineTo(c.x+r,c.y+r); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(c.x+r,c.y-r); ctx.lineTo(c.x-r,c.y+r); ctx.stroke();
|
||
continue;
|
||
}
|
||
const c=this._poseToCanvas(pt[0],pt[1],sx,sy,ox,oy);
|
||
const isKf=isKeyed(label), isInt=isInterp(label);
|
||
const isSel=this.selectedJoint?.label===label;
|
||
const isMulti=this.selectedJoints.has(label);
|
||
ctx.globalAlpha = baseAlpha * conf;
|
||
ctx.fillStyle=isKf?"#ffd700":isInt?"#88aaff":(JOINT_COLORS[i]||"#fff");
|
||
ctx.beginPath();
|
||
if (isKf) { ctx.save();ctx.translate(c.x,c.y);ctx.rotate(Math.PI/4);ctx.fillRect(-5,-5,10,10);ctx.restore(); }
|
||
else { ctx.arc(c.x,c.y,isSel?7:5,0,Math.PI*2); ctx.fill(); }
|
||
ctx.globalAlpha = baseAlpha; // rings always at full (inherited) opacity
|
||
if (isSel) { ctx.strokeStyle="#fff";ctx.lineWidth=2;ctx.beginPath();ctx.arc(c.x,c.y,9,0,Math.PI*2);ctx.stroke(); }
|
||
if (isMulti && !isSel) { ctx.strokeStyle="#5599ff";ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(c.x,c.y,8,0,Math.PI*2);ctx.stroke(); }
|
||
}
|
||
ctx.globalAlpha = baseAlpha;
|
||
const rOff=isDisabled("body",R_WRIST), lOff=isDisabled("body",L_WRIST);
|
||
if (rhand&&!rOff&&!this.hiddenGroups.has("rhand")) this._drawHand(ctx,rhand,RHAND_COLOR,"rhand",ovr,isKeyed,isInterp,sx,sy,ox,oy);
|
||
if (lhand&&!lOff&&!this.hiddenGroups.has("lhand")) this._drawHand(ctx,lhand,LHAND_COLOR,"lhand",ovr,isKeyed,isInterp,sx,sy,ox,oy);
|
||
|
||
// Face landmarks — points-only overlay
|
||
const face = fd.face;
|
||
if (face && !this.hiddenGroups.has("face")) {
|
||
for (let i = 0; i < face.length; i++) {
|
||
const pt = face[i]; if (!pt) continue;
|
||
const conf = showAll ? 1 : (pt[2] ?? 1);
|
||
if (conf < 0.01) continue;
|
||
const label = `face_${i}`;
|
||
if (this.hiddenLayers.has(label)) continue;
|
||
const c = this._poseToCanvas(pt[0], pt[1], sx, sy, ox, oy);
|
||
const isKf = isKeyed(label), isInt = !isKf && isInterp(label);
|
||
const isSel = this.selectedJoint?.label === label;
|
||
const isMulti = this.selectedJoints.has(label);
|
||
ctx.globalAlpha = baseAlpha * conf;
|
||
ctx.fillStyle = isKf ? "#ffd700" : isInt ? "#88aaff" : FACE_COLOR;
|
||
ctx.beginPath();
|
||
if (isKf) { ctx.save(); ctx.translate(c.x,c.y); ctx.rotate(Math.PI/4); ctx.fillRect(-3,-3,6,6); ctx.restore(); }
|
||
else { ctx.arc(c.x, c.y, isSel ? 4 : 2.2, 0, Math.PI*2); ctx.fill(); }
|
||
ctx.globalAlpha = baseAlpha;
|
||
if (isSel) { ctx.strokeStyle = "#fff"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(c.x,c.y,6,0,Math.PI*2); ctx.stroke(); }
|
||
if (isMulti && !isSel) { ctx.strokeStyle = "#5599ff"; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.arc(c.x,c.y,5,0,Math.PI*2); ctx.stroke(); }
|
||
}
|
||
ctx.globalAlpha = baseAlpha;
|
||
}
|
||
if (showAll) {
|
||
const drawMissingHand = (present, off, group, wristIdx, color, label) => {
|
||
if (present || off || this.hiddenGroups.has(group)) return;
|
||
const wp = body[wristIdx]; if (!wp) return;
|
||
const c = this._poseToCanvas(wp[0], wp[1], sx, sy, ox, oy);
|
||
ctx.save(); ctx.globalAlpha = baseAlpha * 0.5;
|
||
ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.setLineDash([3,3]);
|
||
ctx.beginPath(); ctx.arc(c.x, c.y, 16, 0, Math.PI*2); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
ctx.fillStyle = color; ctx.font = "bold 9px sans-serif"; ctx.textAlign = "center";
|
||
ctx.fillText(label, c.x, c.y + 3);
|
||
ctx.textAlign = "left"; ctx.restore();
|
||
};
|
||
drawMissingHand(rhand, rOff, "rhand", R_WRIST, "#0064ff", "R?");
|
||
drawMissingHand(lhand, lOff, "lhand", L_WRIST, "#00c864", "L?");
|
||
}
|
||
|
||
const sg=this.selectedJoint?.group, sl=this.selectedJoint?.label;
|
||
if (ROTATION_ENABLED) {
|
||
if ((sg==="rhand"||sl===`body_${R_WRIST}`)&&rhand) this._drawHandGizmo(ctx,"rhand",rhand,sx,sy,ox,oy);
|
||
if ((sg==="lhand"||sl===`body_${L_WRIST}`)&&lhand) this._drawHandGizmo(ctx,"lhand",lhand,sx,sy,ox,oy);
|
||
}
|
||
|
||
}
|
||
|
||
_drawHand(ctx,kps,color,prefix,ovr,isKeyed,isInterp,sx,sy,ox,oy) {
|
||
const baseAlpha = ctx.globalAlpha;
|
||
const showAll = this._showAll;
|
||
for (const [a,b] of HAND_CONNECTIONS) {
|
||
if (!kps[a]||!kps[b]) continue;
|
||
if (this.hiddenLayers.has(`${prefix}_${a}`)||this.hiddenLayers.has(`${prefix}_${b}`)) continue;
|
||
const boneAlpha = showAll ? 1 : Math.min(kps[a][2]??1, kps[b][2]??1);
|
||
if (boneAlpha < 0.01) continue;
|
||
ctx.globalAlpha = baseAlpha * boneAlpha;
|
||
const pa=this._poseToCanvas(kps[a][0],kps[a][1],sx,sy,ox,oy);
|
||
const pb=this._poseToCanvas(kps[b][0],kps[b][1],sx,sy,ox,oy);
|
||
ctx.strokeStyle=color; ctx.lineWidth=1.5; ctx.beginPath(); ctx.moveTo(pa.x,pa.y); ctx.lineTo(pb.x,pb.y); ctx.stroke();
|
||
}
|
||
for (let i=0;i<kps.length;i++) {
|
||
const pt=kps[i]; if (!pt) continue;
|
||
const conf = showAll ? 1 : (pt[2]??1);
|
||
if (conf < 0.01) continue;
|
||
const label=`${prefix}_${i}`;
|
||
if (this.hiddenLayers.has(label)) continue;
|
||
const c=this._poseToCanvas(pt[0],pt[1],sx,sy,ox,oy);
|
||
const isKf=isKeyed(label), isInt=!isKf&&isInterp(label);
|
||
const isSel=this.selectedJoint?.label===label;
|
||
const isMulti=this.selectedJoints.has(label);
|
||
ctx.globalAlpha = baseAlpha * conf;
|
||
ctx.fillStyle=isKf?"#ffd700":isInt?"#88aaff":color;
|
||
ctx.beginPath();
|
||
if (isKf) { ctx.save();ctx.translate(c.x,c.y);ctx.rotate(Math.PI/4);ctx.fillRect(-4,-4,8,8);ctx.restore(); }
|
||
else { ctx.arc(c.x,c.y,isSel?5:3,0,Math.PI*2); ctx.fill(); }
|
||
ctx.globalAlpha = baseAlpha;
|
||
if (isSel) { ctx.strokeStyle="#fff";ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(c.x,c.y,7,0,Math.PI*2);ctx.stroke(); }
|
||
if (isMulti && !isSel) { ctx.strokeStyle="#5599ff";ctx.lineWidth=1.2;ctx.beginPath();ctx.arc(c.x,c.y,6,0,Math.PI*2);ctx.stroke(); }
|
||
}
|
||
ctx.globalAlpha = baseAlpha;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Hand gizmo
|
||
// -----------------------------------------------------------------------
|
||
_drawHandGizmo(ctx, side, kps, sx, sy, ox, oy) {
|
||
if (!kps || !kps[0]) return;
|
||
const pivot = this._poseToCanvas(kps[0][0], kps[0][1], sx, sy, ox, oy);
|
||
this.gizmoCenter = { x: pivot.x, y: pivot.y, side };
|
||
const R = 32;
|
||
ctx.save();
|
||
ctx.lineWidth = 2.5;
|
||
// Z ring only — flat circle (rotation around Z axis, spins in screen plane)
|
||
ctx.strokeStyle = "#4488ff";
|
||
ctx.beginPath(); ctx.arc(pivot.x, pivot.y, R, 0, Math.PI*2); ctx.stroke();
|
||
ctx.font = "bold 9px sans-serif"; ctx.textAlign = "center";
|
||
ctx.fillStyle = "#6699ff"; ctx.fillText("Z", pivot.x + R + 8, pivot.y + 3);
|
||
ctx.textAlign = "left";
|
||
ctx.restore();
|
||
}
|
||
|
||
_hitTestGizmo(cx, cy) {
|
||
const gc = this.gizmoCenter; if (!gc) return null;
|
||
const dx = cx - gc.x, dy = cy - gc.y;
|
||
const R = 32, TOL = 7;
|
||
// Z ring only
|
||
const distZ = Math.hypot(dx, dy);
|
||
if (distZ >= R - TOL && distZ <= R + TOL) return { axis:"z", side:gc.side };
|
||
return null;
|
||
}
|
||
|
||
_rotateHand(side,axis,angleDeg) {
|
||
const fi=this.currentFrame, fd=this._getEffectiveFrame(fi); if (!fd) return;
|
||
const kps=side==="rhand"?fd.rhand:fd.lhand; if (!kps||kps.length===0) return;
|
||
const pX=kps[0][0],pY=kps[0][1], pZ=kps[0][3]??0;
|
||
const rad=angleDeg*Math.PI/180, cos=Math.cos(rad), sin=Math.sin(rad);
|
||
if (!this.overrides[fi]) this.overrides[fi]={};
|
||
for (let i=0;i<kps.length;i++) {
|
||
const label=`${side}_${i}`;
|
||
const x=kps[i][0],y=kps[i][1],z=kps[i][3]??pZ;
|
||
const dx=x-pX,dy=y-pY,dz=z-pZ;
|
||
let nx,ny,nz;
|
||
if (axis==="x") {nx=dx;ny=dy*cos-dz*sin;nz=dy*sin+dz*cos;}
|
||
else if (axis==="y") {nx=dx*cos+dz*sin;ny=dy;nz=-dx*sin+dz*cos;}
|
||
else {nx=dx*cos-dy*sin;ny=dx*sin+dy*cos;nz=dz;}
|
||
this.overrides[fi][label]=[pX+nx,pY+ny,kps[i][2]??1.0,pZ+nz];
|
||
}
|
||
this._renderFrame(fi); this._renderTrack();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 3D Orbit camera
|
||
// -----------------------------------------------------------------------
|
||
/** Returns a 3×3 rotation matrix for current yaw + pitch. */
|
||
_getOrbitMatrix() {
|
||
const yaw = this.orbitYaw * Math.PI / 180;
|
||
const pitch = this.orbitPitch * Math.PI / 180;
|
||
const cy=Math.cos(yaw), sy=Math.sin(yaw);
|
||
const cp=Math.cos(pitch), sp=Math.sin(pitch);
|
||
// Ry (yaw) * Rx (pitch)
|
||
return {
|
||
m00: cy, m01: sy*sp, m02: sy*cp,
|
||
m10: 0, m11: cp, m12: -sp,
|
||
m20: -sy, m21: cy*sp, m22: cy*cp,
|
||
};
|
||
}
|
||
|
||
/** Project a skeleton-space point to canvas coords using the orbit matrix. */
|
||
_orbitProject(dx, dy, dz, M, scale, ocx, ocy) {
|
||
const rx = M.m00*dx + M.m10*dy + M.m20*dz;
|
||
const ry = M.m01*dx + M.m11*dy + M.m21*dz;
|
||
return { x: ocx + rx * scale, y: ocy + ry * scale };
|
||
}
|
||
|
||
_renderOrbitView(ctx, idx, vW, vH) {
|
||
ctx.fillStyle = "#06060d"; ctx.fillRect(0, 0, vW, vH);
|
||
const fd = this._getEffectiveFrame(idx); if (!fd) return;
|
||
if (ROTATION_ENABLED) this._applyWristRotations(fd, idx);
|
||
const body = fd.body || [], rhand = fd.rhand, lhand = fd.lhand;
|
||
const Z_SCALE = this.poseW * 0.35; // z-depth units → pose-pixel units
|
||
|
||
const scale = Math.min(vW / this.poseW, vH / this.poseH) * 0.82 * this.orbitZoom;
|
||
const ocx = vW / 2 + (this._orbitCenterOffset?.x ?? 0);
|
||
const ocy = vH / 2 + (this._orbitCenterOffset?.y ?? 0);
|
||
const pivX = this.poseW / 2, pivY = this.poseH / 2; // rotation pivot
|
||
|
||
const M = this._getOrbitMatrix();
|
||
const proj = (px, py, pz) =>
|
||
this._orbitProject((px-pivX)*scale, (py-pivY)*scale, pz*Z_SCALE*scale, M, 1, ocx, ocy);
|
||
|
||
// Reference card — a 3D plane behind the skeleton that rotates with the camera
|
||
if (this.showReference) {
|
||
const refImg = this._getRefSource(idx) || this._bgCache[idx];
|
||
if (refImg) {
|
||
const rW = refImg.videoWidth || refImg.naturalWidth || refImg.width || this.poseW;
|
||
const rH = refImg.videoHeight || refImg.naturalHeight || refImg.height || this.poseH;
|
||
const cardW = this.poseW * 0.9, cardH = this.poseH * 0.9;
|
||
const cardZ = -0.8;
|
||
const corners = [
|
||
[-cardW/2, -cardH/2, cardZ],
|
||
[ cardW/2, -cardH/2, cardZ],
|
||
[ cardW/2, cardH/2, cardZ],
|
||
[-cardW/2, cardH/2, cardZ],
|
||
];
|
||
const pts = corners.map(([dx,dy,dz]) =>
|
||
this._orbitProject(dx*scale, dy*scale, dz*Z_SCALE*scale, M, 1, ocx, ocy));
|
||
|
||
const [P0, P1, P2, P3] = pts;
|
||
ctx.save();
|
||
ctx.globalAlpha = this._refMeta?.opacity ?? 0.55;
|
||
this._drawImageTri(ctx, refImg, P0, P1, P2, 0,0, rW,0, rW,rH);
|
||
this._drawImageTri(ctx, refImg, P0, P2, P3, 0,0, rW,rH, 0,rH);
|
||
ctx.globalAlpha = 1;
|
||
ctx.restore();
|
||
} else {
|
||
this._fetchBg(idx);
|
||
}
|
||
}
|
||
|
||
// Draw hint text
|
||
ctx.fillStyle = "#2a3050"; ctx.font = "10px 'Inter',sans-serif";
|
||
ctx.fillText("Drag to orbit · Scroll to zoom", 6, vH - 6);
|
||
|
||
// ── Three.js 3D skeleton (cylinders) ──────────────────────────────────
|
||
if (!this._threeOrbit) {
|
||
this._threeOrbit = new ThreeOrbitRenderer();
|
||
// Re-render once Three.js finishes loading so the 2D fallback swaps to 3D
|
||
if (!THREE) _threeLoadCbs.push(() => this._renderFrame(this.currentFrame));
|
||
}
|
||
|
||
// Skip Three.js perspective layer in orthographic camera mode (matches front view)
|
||
if (this._threeOrbit.ready && !(this.cameraView === "camera" && this._camOrtho)) {
|
||
// In camera mode shift lookAt target to camera aim point in world coords
|
||
let threeLA = null;
|
||
if (this.cameraView === "camera" && this._hasCameraKeyframes()) {
|
||
const cam = this._getInterpolatedCamera(idx);
|
||
threeLA = { x: cam.x * this.poseW, y: -cam.y * this.poseH, z: 0 };
|
||
}
|
||
const outW = this.cameraView === "camera" ? (this._canvasW || this.poseW) : 0;
|
||
const outH = this.cameraView === "camera" ? (this._canvasH || this.poseH) : 0;
|
||
const dwA = this._experimentalMode ? (this._dwposeAlpha ?? 1) : 1;
|
||
const nlfA = this._experimentalMode ? (this._nlfAlpha ?? 0) : 0;
|
||
this._threeOrbit.render(
|
||
ctx, vW, vH, fd,
|
||
this.orbitYaw, this.orbitPitch, this.orbitZoom,
|
||
this.poseW, this.poseH,
|
||
this.zGlobalOffset, this._showAll,
|
||
this.hiddenGroups, this.hiddenLayers, this.selectedJoint, threeLA,
|
||
outW, outH, dwA, this._boneScale ?? 1, nlfA,
|
||
);
|
||
} else {
|
||
// ── 2D fallback (active until Three.js finishes loading) ───────────
|
||
const showAll = this._showAll;
|
||
|
||
// Floor grid
|
||
{
|
||
const STEPS = 9, DX = this.poseW / STEPS, DZ = 0.22;
|
||
ctx.save(); ctx.strokeStyle = "rgba(55,80,120,0.35)"; ctx.lineWidth = 0.6;
|
||
for (let iz = -STEPS; iz <= STEPS; iz++) {
|
||
const gz = iz * DZ;
|
||
const p1 = proj(0, pivY, gz), p2 = proj(this.poseW, pivY, gz);
|
||
ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
|
||
}
|
||
for (let ix = 0; ix <= STEPS; ix++) {
|
||
const gx = ix * DX;
|
||
const p1 = proj(gx, pivY, -STEPS * DZ), p2 = proj(gx, pivY, STEPS * DZ);
|
||
ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
|
||
}
|
||
ctx.strokeStyle = "rgba(80,120,180,0.55)"; ctx.lineWidth = 1;
|
||
const gcx1 = proj(this.poseW/2, pivY, -STEPS*DZ), gcx2 = proj(this.poseW/2, pivY, STEPS*DZ);
|
||
ctx.beginPath(); ctx.moveTo(gcx1.x, gcx1.y); ctx.lineTo(gcx2.x, gcx2.y); ctx.stroke();
|
||
const gcz1 = proj(0, pivY, 0), gcz2 = proj(this.poseW, pivY, 0);
|
||
ctx.beginPath(); ctx.moveTo(gcz1.x, gcz1.y); ctx.lineTo(gcz2.x, gcz2.y); ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// Pivot cross
|
||
const pc = proj(pivX, pivY, 0);
|
||
ctx.strokeStyle = "#223"; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(pc.x-10,pc.y); ctx.lineTo(pc.x+10,pc.y); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(pc.x,pc.y-10); ctx.lineTo(pc.x,pc.y+10); ctx.stroke();
|
||
|
||
const bodyGrpVis = !this.hiddenGroups.has("body");
|
||
const bones = [];
|
||
for (let i = 0; i < BODY_CONNECTIONS.length; i++) {
|
||
const [a,b] = BODY_CONNECTIONS[i];
|
||
if (!body[a]||!body[b]) continue;
|
||
const boneAlpha = showAll ? 1 : Math.min(body[a][2]??1, body[b][2]??1);
|
||
if (boneAlpha < 0.01) continue;
|
||
if (!bodyGrpVis || this.hiddenLayers.has(`body_${a}`) || this.hiddenLayers.has(`body_${b}`)) continue;
|
||
const za = (body[a][3]??0) + (this.zGlobalOffset[`body_${a}`]??0);
|
||
const zb = (body[b][3]??0) + (this.zGlobalOffset[`body_${b}`]??0);
|
||
const dza = (body[a][0]-pivX)*M.m02 + (body[a][1]-pivY)*M.m12 + za*Z_SCALE*M.m22;
|
||
const dzb = (body[b][0]-pivX)*M.m02 + (body[b][1]-pivY)*M.m12 + zb*Z_SCALE*M.m22;
|
||
bones.push({ i, a, b, za, zb, boneAlpha, depth: (dza+dzb)/2 });
|
||
}
|
||
bones.sort((x,y) => x.depth - y.depth);
|
||
for (const { i, a, b, za, zb, boneAlpha } of bones) {
|
||
const pa = proj(body[a][0],body[a][1],za), pb = proj(body[b][0],body[b][1],zb);
|
||
const depthA = 0.5 + Math.max(-0.5, Math.min(0.5, za));
|
||
const depthB = 0.5 + Math.max(-0.5, Math.min(0.5, zb));
|
||
ctx.globalAlpha = boneAlpha * (Math.min(1, 0.5+depthA*0.5) + Math.min(1, 0.5+depthB*0.5)) / 2;
|
||
ctx.strokeStyle = BONE_COLORS[i] || "#666";
|
||
ctx.lineWidth = Math.max(1, 1.5 + (depthA + depthB) * 2.5);
|
||
ctx.lineCap = "round";
|
||
ctx.beginPath(); ctx.moveTo(pa.x,pa.y); ctx.lineTo(pb.x,pb.y); ctx.stroke();
|
||
}
|
||
ctx.globalAlpha = 1; ctx.lineCap = "butt";
|
||
for (let i = 0; i < body.length; i++) {
|
||
const pt = body[i]; if (!pt) continue;
|
||
const conf = showAll ? 1 : (pt[2]??1); if (conf < 0.01) continue;
|
||
if (!bodyGrpVis || this.hiddenLayers.has(`body_${i}`)) continue;
|
||
const z = (pt[3]??0) + (this.zGlobalOffset[`body_${i}`]??0), c = proj(pt[0],pt[1],z);
|
||
const isSel = this.selectedJoint?.group==="body" && this.selectedJoint?.index===i;
|
||
ctx.globalAlpha = conf;
|
||
ctx.fillStyle = isSel ? "#ffd700" : (JOINT_COLORS[i]||"#fff");
|
||
ctx.beginPath(); ctx.arc(c.x, c.y, isSel?8:5.5, 0, Math.PI*2); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
if (isSel) { ctx.strokeStyle="#fff"; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(c.x,c.y,11,0,Math.PI*2); ctx.stroke(); }
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
const rWZ = (body[R_WRIST]?.[3]??0) + (this.zGlobalOffset[`body_${R_WRIST}`]??0);
|
||
const lWZ = (body[L_WRIST]?.[3]??0) + (this.zGlobalOffset[`body_${L_WRIST}`]??0);
|
||
if (rhand && !this.hiddenGroups.has("rhand")) this._drawHandOrbit(ctx, rhand, RHAND_COLOR, "rhand", rWZ, proj);
|
||
if (lhand && !this.hiddenGroups.has("lhand")) this._drawHandOrbit(ctx, lhand, LHAND_COLOR, "lhand", lWZ, proj);
|
||
}
|
||
|
||
// NLF body — rendered by ThreeOrbitRenderer when its perspective layer is active.
|
||
// When that's skipped (camera view + Orthographic), draw NLF as a 2D overlay so
|
||
// it stays visible. The 2D path uses the same `proj()` ortho projection as the
|
||
// body 2D fallback, so they remain at matching scale.
|
||
const _threeActive = this._threeOrbit?.ready
|
||
&& !(this.cameraView === "camera" && this._camOrtho);
|
||
if (!_threeActive && this._experimentalMode && this._nlfAlpha > 0 && this._nlfData) {
|
||
const nlf = fd.nlf_body;
|
||
if (nlf?.length) {
|
||
const valid = (pt) => pt && isFinite(pt[0]) && isFinite(pt[1]) && (pt[2] ?? 0) > 0.05;
|
||
ctx.save();
|
||
ctx.globalAlpha = this._nlfAlpha;
|
||
for (let i = 0; i < BODY_CONNECTIONS.length; i++) {
|
||
const [a, b] = BODY_CONNECTIONS[i];
|
||
if (a >= nlf.length || b >= nlf.length) continue;
|
||
if (!valid(nlf[a]) || !valid(nlf[b])) continue;
|
||
const pa = proj(nlf[a][0], nlf[a][1], nlf[a][3] ?? 0);
|
||
const pb = proj(nlf[b][0], nlf[b][1], nlf[b][3] ?? 0);
|
||
this._drawNlfTube(ctx, pa.x, pa.y, pb.x, pb.y, 4);
|
||
}
|
||
const ovr = this.overrides[idx] || {};
|
||
for (let i = 0; i < nlf.length; i++) {
|
||
const pt = nlf[i]; if (!valid(pt)) continue;
|
||
const p = proj(pt[0], pt[1], pt[3] ?? 0);
|
||
const isKf = ovr[`nlf_body_${i}`] !== undefined;
|
||
this._drawNlfSphere(ctx, p.x, p.y, isKf ? 5 : 4);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// Camera motion path + gizmo — only in external viewports, never when looking through the camera
|
||
if (this.cameraView !== "camera") {
|
||
if (this._hasCameraKeyframes()) {
|
||
const poseW = this.poseW, poseH = this.poseH;
|
||
this._drawCameraPath(ctx, (nx, ny) => proj((nx - 0.5) * poseW, (ny - 0.5) * poseH, 0));
|
||
}
|
||
if (this._hasCameraKeyframes()) {
|
||
this._drawCameraGizmo(ctx, idx, proj, this.poseW, this.poseH);
|
||
}
|
||
}
|
||
|
||
// Selected-joint gizmo — project joint into orbit screen space
|
||
if (this.selectedJoint) {
|
||
const sj = this.selectedJoint;
|
||
const gPts = _grpPts(fd, sj.group);
|
||
const pt = gPts?.[sj.index];
|
||
if (pt && (pt[2] ?? 1) >= 0.01) {
|
||
const z = pt[3] ?? 0;
|
||
const gc = proj(pt[0], pt[1], z);
|
||
this._drawMoveGizmo(ctx, gc.x, gc.y);
|
||
}
|
||
}
|
||
|
||
this._drawViewCube(ctx, vW, vH, M);
|
||
this._drawOrbitAxes(ctx, vW, vH, M);
|
||
}
|
||
|
||
_drawCameraGizmo(ctx, idx, proj, poseW, poseH) {
|
||
const cam = this._getInterpolatedCamera(idx);
|
||
// Blender-style camera frustum: camera body → 4 image-plane corners + up triangle
|
||
const camZoom = Math.tan(30 * Math.PI / 180) / Math.tan(Math.max(1, cam.fov) / 2 * Math.PI / 180);
|
||
|
||
// Compute actual camera 3D position using the same orbit math as camera_math.py:
|
||
// dist = max(poseW,poseH) * 1.4 / (cam.z * camZoom)
|
||
// cam_3d = look_3d + [sin(pan)*cos(tilt), sin(tilt), cos(pan)*cos(tilt)] * dist
|
||
// Convert Three.js world → pixel space: px = x_3d+poseW/2, py = poseH/2-y_3d, pz = z_3d/Z_SCALE
|
||
const dist = Math.max(poseW, poseH) * 1.4 / Math.max(0.01, cam.z * camZoom);
|
||
const pan = cam.pan * Math.PI / 180;
|
||
const tilt = cam.tilt * Math.PI / 180;
|
||
const Z_SCALE = poseW * 0.35;
|
||
|
||
// LookAt point in pixel space (image plane center)
|
||
const lookX = poseW * (0.5 + cam.x);
|
||
const lookY = poseH * (0.5 + cam.y);
|
||
|
||
// Camera body position: look_3d + orbit offset, then pixel-space conversion
|
||
// Three.js Y-up → pixel Y-down: camPY = lookY - sin(tilt)*dist
|
||
const camPX = lookX + Math.sin(pan) * Math.cos(tilt) * dist;
|
||
const camPY = lookY - Math.sin(tilt) * dist;
|
||
const camPZ = Math.cos(pan) * Math.cos(tilt) * dist / Z_SCALE;
|
||
|
||
// Image plane corners centred on lookAt point at z=0
|
||
const hw = (poseW * 0.45) / camZoom;
|
||
const hh = (poseH * 0.45) / camZoom;
|
||
const fTL = proj(lookX - hw, lookY - hh, 0), fTR = proj(lookX + hw, lookY - hh, 0);
|
||
const fBR = proj(lookX + hw, lookY + hh, 0), fBL = proj(lookX - hw, lookY + hh, 0);
|
||
|
||
// Camera origin (lens position)
|
||
const cPos = proj(camPX, camPY, camPZ);
|
||
|
||
// Tiny camera body rectangle at lens position
|
||
const bW = Math.min(poseW, poseH) * 0.035, bH = Math.min(poseW, poseH) * 0.028;
|
||
const bTL = proj(camPX - bW, camPY - bH, camPZ), bTR = proj(camPX + bW, camPY - bH, camPZ);
|
||
const bBR = proj(camPX + bW, camPY + bH, camPZ), bBL = proj(camPX - bW, camPY + bH, camPZ);
|
||
|
||
// Up-triangle directly above camera body (solid, like Blender)
|
||
const uH = bH * 1.8, uW = bW * 0.9;
|
||
const tTip = proj(camPX, camPY - bH - uH, camPZ);
|
||
const tLeft = proj(camPX - uW, camPY - bH, camPZ);
|
||
const tRight = proj(camPX + uW, camPY - bH, camPZ);
|
||
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.88;
|
||
|
||
// 4 frustum lines: camera origin → image plane corners
|
||
ctx.strokeStyle = "#cc8800"; ctx.lineWidth = 1;
|
||
const line = (a, b) => { ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); };
|
||
line(cPos, fTL); line(cPos, fTR); line(cPos, fBR); line(cPos, fBL);
|
||
|
||
// Image plane rectangle (solid, like Blender's near-clip frame)
|
||
ctx.strokeStyle = "#cc8800"; ctx.lineWidth = 1.2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(fTL.x,fTL.y); ctx.lineTo(fTR.x,fTR.y); ctx.lineTo(fBR.x,fBR.y);
|
||
ctx.lineTo(fBL.x,fBL.y); ctx.closePath(); ctx.stroke();
|
||
|
||
// Diagonal cross on image plane (Blender detail)
|
||
ctx.strokeStyle = "#cc8800"; ctx.lineWidth = 0.7; ctx.globalAlpha = 0.5;
|
||
line(fTL, fBR); line(fTR, fBL);
|
||
ctx.globalAlpha = 0.88;
|
||
|
||
// Camera body rectangle (small, at lens position)
|
||
ctx.strokeStyle = "#cc8800"; ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(bTL.x,bTL.y); ctx.lineTo(bTR.x,bTR.y); ctx.lineTo(bBR.x,bBR.y);
|
||
ctx.lineTo(bBL.x,bBL.y); ctx.closePath(); ctx.stroke();
|
||
|
||
// Up-triangle: solid orange fill (Blender style)
|
||
ctx.fillStyle = "#cc8800"; ctx.globalAlpha = 0.9;
|
||
ctx.beginPath();
|
||
ctx.moveTo(tTip.x,tTip.y); ctx.lineTo(tLeft.x,tLeft.y); ctx.lineTo(tRight.x,tRight.y);
|
||
ctx.closePath(); ctx.fill();
|
||
|
||
// Lens origin dot
|
||
ctx.fillStyle = "#ffcc44"; ctx.globalAlpha = 1;
|
||
ctx.beginPath(); ctx.arc(cPos.x, cPos.y, 3, 0, Math.PI*2); ctx.fill();
|
||
|
||
// Label
|
||
ctx.fillStyle = "#cc8800"; ctx.font = "bold 9px sans-serif"; ctx.globalAlpha = 0.9;
|
||
ctx.fillText("CAM", cPos.x + 5, cPos.y - 5);
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
_drawHandOrbit(ctx, kps, color, prefix, wristZ, proj) {
|
||
const showAll = this._showAll;
|
||
for (const [a,b] of HAND_CONNECTIONS) {
|
||
if (!kps[a]||!kps[b]) continue;
|
||
if (this.hiddenLayers.has(`${prefix}_${a}`)||this.hiddenLayers.has(`${prefix}_${b}`)) continue;
|
||
const boneAlpha = showAll ? 1 : Math.min(kps[a][2]??1, kps[b][2]??1);
|
||
if (boneAlpha < 0.01) continue;
|
||
const za = kps[a][3]??wristZ, zb = kps[b][3]??wristZ;
|
||
const pa = proj(kps[a][0],kps[a][1],za), pb = proj(kps[b][0],kps[b][1],zb);
|
||
ctx.globalAlpha = boneAlpha;
|
||
ctx.strokeStyle = color; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(pa.x,pa.y); ctx.lineTo(pb.x,pb.y); ctx.stroke();
|
||
}
|
||
for (let i = 0; i < kps.length; i++) {
|
||
const pt = kps[i]; if (!pt) continue;
|
||
const conf = showAll ? 1 : (pt[2]??1); if (conf < 0.01) continue;
|
||
if (this.hiddenLayers.has(`${prefix}_${i}`)) continue;
|
||
const z = pt[3]??wristZ, c = proj(pt[0],pt[1],z);
|
||
const isSel = this.selectedJoint?.group===prefix && this.selectedJoint?.index===i;
|
||
ctx.globalAlpha = conf;
|
||
ctx.fillStyle = isSel ? "#ffd700" : color;
|
||
ctx.beginPath(); ctx.arc(c.x, c.y, isSel?5:3, 0, Math.PI*2); ctx.fill();
|
||
ctx.globalAlpha = 1;
|
||
if (isSel) { ctx.strokeStyle="#fff"; ctx.lineWidth=1.5; ctx.beginPath(); ctx.arc(c.x,c.y,7,0,Math.PI*2); ctx.stroke(); }
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
/** Draw a small XYZ axis gizmo in the corner to show current orbit orientation. */
|
||
/** Blender-style 3-axis gizmo bottom-left of orbit/camera viewports. */
|
||
_drawOrbitAxes(ctx, vW, vH, M) {
|
||
const ox = 32, oy = vH - 32, len = 22, R = 7;
|
||
const proj2D = (x,y,z) => ({
|
||
x: ox + (M.m00*x + M.m10*y + M.m20*z) * len,
|
||
y: oy + (M.m01*x + M.m11*y + M.m21*z) * len,
|
||
d: (M.m02*x + M.m12*y + M.m22*z), // depth — back-axis dimmed
|
||
});
|
||
// Subtle background disc so the gizmo reads against the skeleton
|
||
ctx.save();
|
||
ctx.fillStyle = "rgba(8,12,22,0.55)";
|
||
ctx.beginPath(); ctx.arc(ox, oy, len + 12, 0, Math.PI * 2); ctx.fill();
|
||
const tips = [
|
||
{ p: proj2D( 1,0,0), col: "#ff5e5e", lbl: "X" },
|
||
{ p: proj2D( 0,1,0), col: "#5fdd5f", lbl: "Y" },
|
||
{ p: proj2D( 0,0,1), col: "#6aa6ff", lbl: "Z" },
|
||
];
|
||
// Sort back-to-front so closer axes overlap further ones
|
||
tips.sort((a, b) => a.p.d - b.p.d);
|
||
for (const t of tips) {
|
||
const fwd = t.p.d >= -0.001;
|
||
ctx.globalAlpha = fwd ? 1 : 0.45;
|
||
// Axis line
|
||
ctx.strokeStyle = t.col; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(ox, oy); ctx.lineTo(t.p.x, t.p.y); ctx.stroke();
|
||
// Tip bubble + letter
|
||
ctx.fillStyle = fwd ? t.col : "#22293e";
|
||
ctx.beginPath(); ctx.arc(t.p.x, t.p.y, R, 0, Math.PI * 2); ctx.fill();
|
||
ctx.strokeStyle = t.col; ctx.lineWidth = 1.2;
|
||
ctx.beginPath(); ctx.arc(t.p.x, t.p.y, R, 0, Math.PI * 2); ctx.stroke();
|
||
ctx.fillStyle = fwd ? "#0a0e18" : t.col;
|
||
ctx.font = "bold 9px sans-serif";
|
||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
ctx.fillText(t.lbl, t.p.x, t.p.y + 0.5);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
|
||
ctx.restore();
|
||
}
|
||
|
||
/** ViewCube — top-right corner of 3D viewports; shows current orbit orientation as a 3D cube. */
|
||
_drawViewCube(ctx, vW, vH, M) {
|
||
const SZ = 46, cx = vW - SZ - 12, cy = 12 + SZ;
|
||
const r = SZ * 0.5;
|
||
const VERTS = [
|
||
[-1,-1,-1],[+1,-1,-1],[+1,+1,-1],[-1,+1,-1],
|
||
[-1,-1,+1],[+1,-1,+1],[+1,+1,+1],[-1,+1,+1],
|
||
];
|
||
const FACES = [
|
||
{ v:[4,5,6,7], n:[0,0, 1], lbl:'F', col:'#1e3d7a', bright:'#2a5aaa' },
|
||
{ v:[1,0,3,2], n:[0,0,-1], lbl:'B', col:'#102040', bright:'#163060' },
|
||
{ v:[3,7,6,2], n:[0, 1,0], lbl:'T', col:'#1a4268', bright:'#235a8a' },
|
||
{ v:[0,1,5,4], n:[0,-1,0], lbl:'Bo', col:'#0e1e38', bright:'#142840' },
|
||
{ v:[0,4,7,3], n:[-1,0,0], lbl:'L', col:'#162e5e', bright:'#1e3e7a' },
|
||
{ v:[1,2,6,5], n:[ 1,0,0], lbl:'R', col:'#142c5c', bright:'#1c3c78' },
|
||
];
|
||
const proj = (x, y, z) => ({
|
||
x: cx + (M.m00*x + M.m10*y + M.m20*z) * r * 0.64,
|
||
y: cy + (M.m01*x + M.m11*y + M.m21*z) * r * 0.64,
|
||
d: M.m02*x + M.m12*y + M.m22*z,
|
||
});
|
||
const pv = VERTS.map(([x,y,z]) => proj(x,y,z));
|
||
const sorted = FACES.map(f => {
|
||
const d = f.v.reduce((s,i) => s + pv[i].d, 0) / 4;
|
||
return { ...f, depth: d };
|
||
}).sort((a,b) => a.depth - b.depth);
|
||
ctx.save();
|
||
// Background disc
|
||
ctx.fillStyle = 'rgba(6,8,18,0.72)';
|
||
ctx.beginPath(); ctx.arc(cx, cy, r + 8, 0, Math.PI*2); ctx.fill();
|
||
// Draw faces back-to-front
|
||
for (const face of sorted) {
|
||
const pts = face.v.map(i => pv[i]);
|
||
const visible = face.depth > -0.05;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pts[0].x, pts[0].y);
|
||
for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k].x, pts[k].y);
|
||
ctx.closePath();
|
||
ctx.fillStyle = visible ? face.bright : face.col;
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#3a5aaa';
|
||
ctx.lineWidth = 0.8;
|
||
ctx.stroke();
|
||
if (visible && r > 12) {
|
||
const fcx = pts.reduce((s,p) => s+p.x, 0) / 4;
|
||
const fcy = pts.reduce((s,p) => s+p.y, 0) / 4;
|
||
ctx.fillStyle = 'rgba(180,215,255,0.9)';
|
||
ctx.font = `bold ${Math.max(7, Math.round(r * 0.28))}px 'Inter',sans-serif`;
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(face.lbl, fcx, fcy);
|
||
}
|
||
}
|
||
// Outer ring highlight
|
||
ctx.strokeStyle = 'rgba(91,196,255,0.3)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.arc(cx, cy, r + 7, 0, Math.PI*2); ctx.stroke();
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||
ctx.restore();
|
||
}
|
||
|
||
/**
|
||
* Draw a triangle of an image onto canvas using affine (shear) mapping.
|
||
* Works correctly for orthographic-projected parallelograms.
|
||
* dPt = destination canvas {x,y}, sPt = source image [sx,sy]
|
||
*/
|
||
_drawImageTri(ctx, img, dA, dB, dC, sAx,sAy, sBx,sBy, sCx,sCy) {
|
||
// Compute affine matrix from src → dst triangle
|
||
const dx = [dB.x-dA.x, dC.x-dA.x];
|
||
const dy = [dB.y-dA.y, dC.y-dA.y];
|
||
const dsx = [sBx-sAx, sCx-sAx];
|
||
const dsy = [sBy-sAy, sCy-sAy];
|
||
const det = dsx[0]*dsy[1] - dsx[1]*dsy[0];
|
||
if (Math.abs(det) < 0.001) return;
|
||
const a = (dx[0]*dsy[1] - dx[1]*dsy[0]) / det;
|
||
const b = (dy[0]*dsy[1] - dy[1]*dsy[0]) / det;
|
||
const c = (dx[1]*dsx[0] - dx[0]*dsx[1]) / det;
|
||
const d2= (dy[1]*dsx[0] - dy[0]*dsx[1]) / det;
|
||
const e = dA.x - a*sAx - c*sAy;
|
||
const f = dA.y - b*sAx - d2*sAy;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.moveTo(dA.x, dA.y); ctx.lineTo(dB.x, dB.y); ctx.lineTo(dC.x, dC.y);
|
||
ctx.closePath(); ctx.clip();
|
||
ctx.transform(a, b, c, d2, e, f);
|
||
ctx.drawImage(img, 0, 0);
|
||
ctx.restore();
|
||
}
|
||
|
||
/** Hit-test joints in orbit view — returns {group, index, label} or null. */
|
||
_hitTestOrbit(cx, cy, vW, vH) {
|
||
const fd = this._getEffectiveFrame(this.currentFrame); if (!fd) return null;
|
||
const M = this._getOrbitMatrix();
|
||
const Z_SCALE = this.poseW * 0.35;
|
||
const scale = Math.min(vW / this.poseW, vH / this.poseH) * 0.82 * this.orbitZoom;
|
||
const ocx = vW/2, ocy = vH/2, pivX = this.poseW/2, pivY = this.poseH/2;
|
||
const proj = (px,py,pz) =>
|
||
this._orbitProject((px-pivX)*scale,(py-pivY)*scale,pz*Z_SCALE*scale,M,1,ocx,ocy);
|
||
|
||
let best = null, bestD = 14;
|
||
const test = (px, py, pz, group, i) => {
|
||
const label = `${group}_${i}`;
|
||
if (this.hiddenGroups.has(group) || this.hiddenLayers.has(label)) return;
|
||
const c = proj(px,py,pz);
|
||
const d = Math.hypot(cx-c.x, cy-c.y);
|
||
if (d < bestD) { bestD=d; best={group, index:i, label}; }
|
||
};
|
||
const body = fd.body||[];
|
||
for (let i=0;i<body.length;i++) {
|
||
const pt=body[i]; if (!pt||pt[2]<0.01) continue;
|
||
test(pt[0],pt[1],pt[3]??0,"body",i);
|
||
}
|
||
const rWZ = body[R_WRIST]?.[3]??0, lWZ = body[L_WRIST]?.[3]??0;
|
||
for (const [side,kps,wZ] of [["rhand",fd.rhand,rWZ],["lhand",fd.lhand,lWZ]]) {
|
||
if (!kps || this.hiddenGroups.has(side)) continue;
|
||
for (let i=0;i<kps.length;i++) {
|
||
const pt=kps[i]; if (!pt) continue;
|
||
test(pt[0],pt[1],pt[3]??wZ,side,i);
|
||
}
|
||
}
|
||
if (fd.face && !this.hiddenGroups.has("face")) {
|
||
const headZ = body[0]?.[3] ?? 0;
|
||
for (let i=0;i<fd.face.length;i++) {
|
||
const pt=fd.face[i]; if (!pt||(pt[2]??1)<0.01) continue;
|
||
test(pt[0],pt[1],pt[3]??headZ,"face",i);
|
||
}
|
||
}
|
||
// NLF joints — use EFFECTIVE NLF data (raw + overrides), now editable
|
||
if (this._experimentalMode && this._nlfAlpha > 0 && this._nlfData) {
|
||
const nlfPts = fd?.nlf_body;
|
||
if (nlfPts) {
|
||
for (let i = 0; i < nlfPts.length; i++) {
|
||
const p = nlfPts[i]; if (!p || (p[2] ?? 0) < 0.05) continue;
|
||
const z = p[3] ?? 0;
|
||
const c = proj(p[0], p[1], z);
|
||
const d = Math.hypot(cx - c.x, cy - c.y);
|
||
if (d < bestD) { bestD = d; best = { group: "nlf_body", index: i, label: `nlf_body_${i}` }; }
|
||
}
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
_toggleReference() {
|
||
this.showReference = !this.showReference;
|
||
this.refToggleBtn.textContent = `👁 Reference: ${this.showReference?"ON":"OFF"}`;
|
||
this.refToggleBtn.style.background = this.showReference ? "#1a2a1a" : "#2a1a1a";
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
_translateHand(fi, group, dX, dY, startIdx = 0) {
|
||
if (Math.abs(dX) < 0.0001 && Math.abs(dY) < 0.0001) return;
|
||
const rawPts = group === "rhand" ? this.frames[fi]?.rhand : this.frames[fi]?.lhand;
|
||
const count = rawPts ? rawPts.length : 21;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const ovr = this.overrides[fi];
|
||
const hasJoints = rawPts || Object.keys(ovr).some(k => k.startsWith(`${group}_`));
|
||
if (!hasJoints) return;
|
||
for (let i = startIdx; i < count; i++) {
|
||
const lbl = `${group}_${i}`;
|
||
const base = ovr[lbl] || rawPts?.[i];
|
||
if (!base) continue;
|
||
ovr[lbl] = [base[0] + dX, base[1] + dY, base[2] ?? 1.0, base[3] ?? 0];
|
||
}
|
||
}
|
||
|
||
_getRefSource(idx) {
|
||
if (this.referenceImg) return this.referenceImg;
|
||
if (this._refVideo && this._refVideo.readyState >= 2) return this._refVideo;
|
||
if (this._refSeqFrames?.length) {
|
||
const i = Math.max(0, Math.min(this._refSeqFrames.length - 1,
|
||
idx + (this._refMeta?.frameOffset || 0)));
|
||
return this._refSeqFrames[i] || null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
_loadRefVideo(file) {
|
||
if (this._refVideo) this._refVideo.src = "";
|
||
this._refVideo = document.createElement("video");
|
||
this._refVideo.muted = true;
|
||
this._refVideo.preload = "auto";
|
||
this._refVideo.src = URL.createObjectURL(file);
|
||
this.referenceImg = null;
|
||
this._refSeqFrames = null;
|
||
this._refMeta = { type: "video", name: file.name, frameOffset: 0, opacity: 0.55 };
|
||
if (this._refOffsetInp) this._refOffsetInp.value = "0";
|
||
this._refVideo.addEventListener("loadedmetadata", () => {
|
||
const v = this._refVideo;
|
||
const dur = v.duration;
|
||
const projectFps = parseFloat(this._fpsInput?.value) || 24;
|
||
const estFrames = Math.round(dur * projectFps);
|
||
if (this._refInfoDiv) {
|
||
this._refInfoDiv.textContent =
|
||
`${v.videoWidth}×${v.videoHeight} · ${dur.toFixed(2)}s · ~${estFrames} frames @ ${projectFps}fps`;
|
||
this._refInfoDiv.style.display = "block";
|
||
}
|
||
this._seekRefVideo(this.currentFrame);
|
||
}, { once: true });
|
||
}
|
||
|
||
_seekRefVideo(fi) {
|
||
if (!this._refVideo) return;
|
||
const fps = parseFloat(this._fpsInput?.value) || 24;
|
||
const t = Math.max(0, (fi + (this._refMeta?.frameOffset || 0)) / fps);
|
||
if (Math.abs(this._refVideo.currentTime - t) < 0.001) {
|
||
this._renderFrame(this.currentFrame);
|
||
return;
|
||
}
|
||
this._refVideo.currentTime = t;
|
||
this._refVideo.addEventListener("seeked", () => this._renderFrame(this.currentFrame), { once: true });
|
||
}
|
||
|
||
_loadRefSequence(files) {
|
||
const sorted = [...files].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||
this._refSeqFrames = new Array(sorted.length);
|
||
this.referenceImg = null;
|
||
if (this._refVideo) { this._refVideo.src = ""; this._refVideo = null; }
|
||
this._refMeta = { type: "sequence", name: `${sorted[0].name} (+${sorted.length - 1})`, frameOffset: 0, opacity: 0.55 };
|
||
if (this._refOffsetInp) this._refOffsetInp.value = "0";
|
||
let loaded = 0;
|
||
for (let i = 0; i < sorted.length; i++) {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
if (++loaded === sorted.length) {
|
||
if (this._refInfoDiv) {
|
||
const first = this._refSeqFrames[0];
|
||
const projectFps = parseFloat(this._fpsInput?.value) || 24;
|
||
this._refInfoDiv.textContent =
|
||
`${first.naturalWidth}×${first.naturalHeight} · ${sorted.length} frames · ${(sorted.length / projectFps).toFixed(2)}s @ ${projectFps}fps`;
|
||
this._refInfoDiv.style.display = "block";
|
||
}
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
};
|
||
img.src = URL.createObjectURL(sorted[i]);
|
||
this._refSeqFrames[i] = img;
|
||
}
|
||
}
|
||
|
||
_showRelinkBanner() {
|
||
if (!this._refRelinkBanner || !this._refMeta) return;
|
||
const { type, name } = this._refMeta;
|
||
this._refRelinkBanner.innerHTML = "";
|
||
const msg = document.createElement("span");
|
||
msg.textContent = `⚠ "${name}" — `;
|
||
const relinkBtn = document.createElement("button");
|
||
relinkBtn.textContent = "Re-link…";
|
||
relinkBtn.style.cssText = "background:#3a2a00;border:1px solid #aa6622;color:#ffcc66;border-radius:3px;padding:1px 6px;font-size:10px;cursor:pointer;";
|
||
relinkBtn.addEventListener("click", () => {
|
||
if (type === "video") this._refVideoInput?.click();
|
||
else if (type === "sequence") this._refSeqInput?.click();
|
||
else this._refFileInput?.click();
|
||
});
|
||
const closeBtn = document.createElement("button");
|
||
closeBtn.textContent = "×";
|
||
closeBtn.style.cssText = "background:none;border:none;color:#aa7;font-size:13px;cursor:pointer;margin-left:4px;line-height:1;";
|
||
closeBtn.addEventListener("click", () => { this._refRelinkBanner.style.display = "none"; });
|
||
this._refRelinkBanner.append(msg, relinkBtn, closeBtn);
|
||
this._refRelinkBanner.style.display = "block";
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Multi-panel layout helpers
|
||
// -----------------------------------------------------------------------
|
||
_getPanelRects(cw, ch) {
|
||
if (this._panelLayout === 4) {
|
||
const hw = Math.floor(cw / 2), hh = Math.floor(ch / 2);
|
||
return [
|
||
{ x:0, y:0, w:hw, h:hh },
|
||
{ x:hw, y:0, w:cw-hw, h:hh },
|
||
{ x:0, y:hh, w:hw, h:ch-hh },
|
||
{ x:hw, y:hh, w:cw-hw, h:ch-hh },
|
||
];
|
||
}
|
||
if (this._panelLayout === 2) {
|
||
const hw = Math.floor(cw / 2);
|
||
return [
|
||
{ x:0, y:0, w:hw, h:ch },
|
||
{ x:hw, y:0, w:cw-hw, h:ch },
|
||
];
|
||
}
|
||
return [{ x:0, y:0, w:cw, h:ch }];
|
||
}
|
||
|
||
_loadPanelState(i) {
|
||
const s = this._panelState[i];
|
||
this.vpZoom = s.vpZoom; this.vpPanX = s.vpPanX; this.vpPanY = s.vpPanY;
|
||
this.orbitYaw = s.orbitYaw; this.orbitPitch = s.orbitPitch; this.orbitZoom = s.orbitZoom;
|
||
}
|
||
|
||
_savePanelState(i) {
|
||
const s = this._panelState[i];
|
||
s.vpZoom = this.vpZoom; s.vpPanX = this.vpPanX; s.vpPanY = this.vpPanY;
|
||
s.orbitYaw = this.orbitYaw; s.orbitPitch = this.orbitPitch; s.orbitZoom = this.orbitZoom;
|
||
}
|
||
|
||
_drawPanelHeader(ctx, panelIdx, rect, view) {
|
||
const H = PANEL_HEADER_H;
|
||
const isActive = panelIdx === this._activePanel;
|
||
// Background
|
||
ctx.fillStyle = isActive ? "#141c36" : "#0d1020";
|
||
ctx.fillRect(rect.x, rect.y, rect.w, H);
|
||
// Accent line at bottom of header (top of content)
|
||
if (isActive) {
|
||
ctx.fillStyle = "#5bc4ff";
|
||
ctx.fillRect(rect.x, rect.y + H - 2, rect.w, 2);
|
||
} else {
|
||
ctx.fillStyle = "#1e2540";
|
||
ctx.fillRect(rect.x, rect.y + H - 1, rect.w, 1);
|
||
}
|
||
// View name + dropdown caret — click anywhere on header opens menu
|
||
const lbl = VIEW_LABELS[view] ?? view.toUpperCase();
|
||
ctx.font = "bold 10px 'JetBrains Mono',monospace";
|
||
ctx.textBaseline = "middle";
|
||
ctx.textAlign = "center";
|
||
ctx.fillStyle = isActive ? "#d4ddf4" : "#4a5a78";
|
||
ctx.fillText(`${lbl} ▾`, rect.x + rect.w / 2, rect.y + H / 2 - 1);
|
||
ctx.textBaseline = "alphabetic";
|
||
}
|
||
|
||
_cyclePanelView(panelIdx, dir) {
|
||
const cur = this._panelViews[panelIdx] ?? "front";
|
||
const ci = VIEW_CYCLE.indexOf(cur);
|
||
const ni = (ci + dir + VIEW_CYCLE.length) % VIEW_CYCLE.length;
|
||
this._panelViews[panelIdx] = VIEW_CYCLE[ni];
|
||
if (panelIdx === this._activePanel) {
|
||
this.cameraView = VIEW_CYCLE[ni];
|
||
this._updateLayoutBtns();
|
||
}
|
||
}
|
||
|
||
/** Show a dropdown of available views for the given panel, anchored at viewport (clientX, clientY). */
|
||
_showViewMenu(panelIdx, clientX, clientY) {
|
||
// Close any existing menu first
|
||
if (this._viewMenuEl) { this._viewMenuEl.remove(); this._viewMenuEl = null; }
|
||
const cur = this._panelViews[panelIdx] ?? "front";
|
||
const menu = document.createElement("div");
|
||
Object.assign(menu.style, {
|
||
position: "fixed", left: `${clientX}px`, top: `${clientY}px`,
|
||
background: "#0f1224", border: "1px solid #2c3a5a", borderRadius: "4px",
|
||
padding: "4px 0", minWidth: "120px",
|
||
boxShadow: "0 6px 18px rgba(0,0,0,0.65)", zIndex: "100001",
|
||
font: "11px 'JetBrains Mono',monospace", userSelect: "none",
|
||
});
|
||
const VIEW_TIPS = {
|
||
front: "Front orthographic view (Y-up)",
|
||
back: "Back orthographic view (front mirrored on X)",
|
||
top: "Top orthographic view (looks down -Y)",
|
||
side: "Side orthographic view (looks down +X)",
|
||
orbit: "Free 3D orbit camera (drag to rotate)",
|
||
camera: "Animated scene camera (uses camera keyframes)",
|
||
};
|
||
for (const v of VIEW_CYCLE) {
|
||
const item = document.createElement("div");
|
||
item.textContent = VIEW_LABELS[v] ?? v.toUpperCase();
|
||
item.title = VIEW_TIPS[v] || "";
|
||
const isSel = v === cur;
|
||
Object.assign(item.style, {
|
||
padding: "5px 14px", cursor: "pointer",
|
||
color: isSel ? "#f7c840" : "#b0bcd4",
|
||
background: isSel ? "#162040" : "transparent",
|
||
fontWeight: isSel ? "bold" : "normal",
|
||
});
|
||
item.addEventListener("mouseenter", () => { if (!isSel) item.style.background = "#151e38"; });
|
||
item.addEventListener("mouseleave", () => { if (!isSel) item.style.background = "transparent"; });
|
||
item.addEventListener("click", e => {
|
||
e.stopPropagation();
|
||
this._panelViews[panelIdx] = v;
|
||
if (panelIdx === this._activePanel) this.cameraView = v;
|
||
this._updateLayoutBtns();
|
||
this._renderFrame(this.currentFrame);
|
||
menu.remove(); this._viewMenuEl = null;
|
||
});
|
||
menu.appendChild(item);
|
||
}
|
||
document.body.appendChild(menu);
|
||
this._viewMenuEl = menu;
|
||
// Clamp to viewport edges if it would overflow
|
||
const r = menu.getBoundingClientRect();
|
||
if (r.right > window.innerWidth) menu.style.left = `${window.innerWidth - r.width - 4}px`;
|
||
if (r.bottom > window.innerHeight) menu.style.top = `${window.innerHeight - r.height - 4}px`;
|
||
// Dismiss on outside click / Escape (next tick to avoid catching the spawning click)
|
||
const dismiss = (ev) => {
|
||
if (ev.type === "keydown" && ev.key !== "Escape") return;
|
||
if (ev.type === "mousedown" && menu.contains(ev.target)) return;
|
||
menu.remove(); this._viewMenuEl = null;
|
||
document.removeEventListener("mousedown", dismiss, true);
|
||
document.removeEventListener("keydown", dismiss, true);
|
||
};
|
||
setTimeout(() => {
|
||
document.addEventListener("mousedown", dismiss, true);
|
||
document.addEventListener("keydown", dismiss, true);
|
||
}, 0);
|
||
}
|
||
|
||
_setLayout(n) {
|
||
this._panelLayout = n;
|
||
// Ensure active panel is valid
|
||
const maxPanels = n;
|
||
if (this._activePanel >= maxPanels) {
|
||
this._savePanelState(this._activePanel);
|
||
this._activePanel = 0;
|
||
}
|
||
this._loadPanelState(this._activePanel);
|
||
this.cameraView = this._panelViews[this._activePanel] ?? "front";
|
||
this._updateLayoutBtns();
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
_updateLayoutBtns() {
|
||
if (this._layoutBtns) {
|
||
for (const [n, btn] of Object.entries(this._layoutBtns)) {
|
||
const active = parseInt(n) === this._panelLayout;
|
||
btn.style.background = active ? "#2a5080" : "#1e3a5a";
|
||
btn.style.color = active ? "#fff" : "#88aacc";
|
||
btn.style.borderColor = active ? "#4488cc" : "#2a4a6a";
|
||
}
|
||
}
|
||
const view = this.cameraView;
|
||
this._resetViewBtn.style.display = view !== "front" ? "block" : "none";
|
||
}
|
||
|
||
_setCameraView(view) {
|
||
this._panelViews[this._activePanel] = view;
|
||
this.cameraView = view;
|
||
this._updateLayoutBtns();
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
_updateCamBtns() { this._updateLayoutBtns(); }
|
||
|
||
_resetView() {
|
||
const s = this._panelState[this._activePanel];
|
||
s.vpZoom = 1.0; s.vpPanX = 0; s.vpPanY = 0;
|
||
s.orbitYaw = -20; s.orbitPitch = 15; s.orbitZoom = 1.0;
|
||
this._loadPanelState(this._activePanel);
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
_onAddHandClick() {
|
||
const fd = this._getEffectiveFrame(this.currentFrame);
|
||
const hasR = !!(fd?.rhand);
|
||
const hasL = !!(fd?.lhand);
|
||
if (!hasR && hasL) { this._addHand("rhand"); return; }
|
||
if (!hasL && hasR) { this._addHand("lhand"); return; }
|
||
// Both missing or both present — show chooser
|
||
this._addHandChooser.style.display =
|
||
this._addHandChooser.style.display === "flex" ? "none" : "flex";
|
||
}
|
||
|
||
_addHand(side) {
|
||
const fi = this.currentFrame;
|
||
const fd = this._getEffectiveFrame(fi); if (!fd) return;
|
||
const wristIdx = side === "rhand" ? R_WRIST : L_WRIST;
|
||
const wp = fd.body?.[wristIdx];
|
||
if (!wp) { console.warn("[AddHand] Wrist joint not found on this frame."); return; }
|
||
const template = side === "rhand" ? DEFAULT_RHAND_PX : DEFAULT_LHAND_PX;
|
||
const W = this.poseW || 512, H = this.poseH || 512;
|
||
this._pushUndo();
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
for (let i = 0; i < 21; i++) {
|
||
const [dx, dy] = template[i];
|
||
this.overrides[fi][`${side}_${i}`] = [
|
||
Math.max(0, Math.min(W, wp[0] + dx)),
|
||
Math.max(0, Math.min(H, wp[1] + dy)),
|
||
1.0, 0,
|
||
];
|
||
}
|
||
this._renderFrame(fi); this._renderTrack(); this._updateDetailPanels();
|
||
}
|
||
|
||
_toggleShowAll() {
|
||
this._showAll = !this._showAll;
|
||
const on = this._showAll;
|
||
Object.assign(this._showAllBtn.style, {
|
||
background: on ? "#2a2400" : "#1a1a2a",
|
||
color: on ? "#ffdd44" : "#556",
|
||
borderColor: on ? "#887700" : "#333",
|
||
});
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
async _fetchBg(idx) {
|
||
if (this._bgPending.has(idx)) return;
|
||
this._bgPending.add(idx);
|
||
try {
|
||
const r=await fetch(`/temporal-editor/background/${this.nodeId}/${idx}`);
|
||
const d=await r.json();
|
||
if (d.image) {
|
||
const img=new Image();
|
||
img.onload=()=>{this._bgCache[idx]=img;if(this.currentFrame===idx)this._renderFrame(idx);};
|
||
img.src=`data:image/png;base64,${d.image}`;
|
||
}
|
||
} catch(_){}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Hit testing
|
||
// -----------------------------------------------------------------------
|
||
_activeFrontViewW() {
|
||
const cw = this.canvas.width, ch = this.canvas.height;
|
||
return (this._getPanelRects(cw, ch)[this._activePanel]?.w) ?? cw;
|
||
}
|
||
_activeFrontViewH() {
|
||
const cw = this.canvas.width, ch = this.canvas.height;
|
||
const r = this._getPanelRects(cw, ch)[this._activePanel];
|
||
return r ? r.h - PANEL_HEADER_H : ch;
|
||
}
|
||
|
||
_hitTest(lx, ly) {
|
||
const vW = this._activeFrontViewW(), vH = this._activeFrontViewH();
|
||
const {sx,sy,ox,oy}=this._getFrontTransform(vW,vH);
|
||
const THRESH=12, fd=this._getEffectiveFrame(this.currentFrame); if (!fd) return null;
|
||
let best=null, bestD=THRESH;
|
||
for (const {group,pts} of [{group:"body",pts:fd.body||[]},{group:"rhand",pts:fd.rhand||[]},{group:"lhand",pts:fd.lhand||[]},{group:"face",pts:fd.face||[]}]) {
|
||
if (!pts || this.hiddenGroups.has(group)) continue;
|
||
for (let i=0;i<pts.length;i++) {
|
||
const pt=pts[i]; if (!pt) continue;
|
||
if ((pt[2]??1) < 0.01) continue;
|
||
const label=`${group}_${i}`;
|
||
if (this.hiddenLayers.has(label)) continue;
|
||
const c=this._poseToCanvas(pt[0],pt[1],sx,sy,ox,oy);
|
||
const d=Math.hypot(lx-c.x,ly-c.y);
|
||
if (d<bestD){bestD=d;best={group,index:i,label};}
|
||
}
|
||
}
|
||
// NLF ghost joints — read-only, selectable when overlay is visible
|
||
const nlfHit = this._hitTestNlf(lx, ly, sx, sy, ox, oy, bestD);
|
||
if (nlfHit) { best = nlfHit; bestD = nlfHit._d; }
|
||
// Camera aim point — draggable in front/back views
|
||
if (this._hasCameraKeyframes()) {
|
||
const cam = this._getInterpolatedCamera(this.currentFrame);
|
||
const aimX = ox + (0.5 + cam.x) * this.poseW * sx;
|
||
const aimY = oy + (0.5 + cam.y) * this.poseH * sy;
|
||
if (Math.hypot(lx - aimX, ly - aimY) < THRESH)
|
||
return { group:"camera", index:-1, label:"camera_aim", isCameraAim:true };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** Test NLF nlf_body joints against a front/back canvas point.
|
||
* Uses the EFFECTIVE NLF data (raw + overrides) so edited positions are clickable.
|
||
* Returns a hit object {group:"nlf_body", index, label, _d} or null. */
|
||
_hitTestNlf(lx, ly, sx, sy, ox, oy, maxD) {
|
||
if (!this._experimentalMode || !(this._nlfAlpha > 0) || !this._nlfData) return null;
|
||
const fd = this._getEffectiveFrame(this.currentFrame);
|
||
const pts = fd?.nlf_body;
|
||
if (!pts) return null;
|
||
let hit = null, hitD = maxD;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const p = pts[i]; if (!p || (p[2] ?? 0) < 0.05) continue;
|
||
const c = this._poseToCanvas(p[0], p[1], sx, sy, ox, oy);
|
||
const d = Math.hypot(lx - c.x, ly - c.y);
|
||
if (d < hitD) { hitD = d; hit = { group: "nlf_body", index: i, label: `nlf_body_${i}`, _d: d }; }
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
_hitTestOrtho(lx, ly, vW, vH, axis) {
|
||
const MARGIN = 0.10;
|
||
const scale = Math.min(vW, vH) * (1 - MARGIN * 2) * this.vpZoom;
|
||
const cx = vW / 2 + this.vpPanX, cy = vH / 2 + this.vpPanY;
|
||
const Z_RANGE = 1.5;
|
||
const fi = this.currentFrame;
|
||
const fd = this._getEffectiveFrame(fi); if (!fd) return null;
|
||
const body = fd.body || [];
|
||
const pW = this.poseW, pH = this.poseH;
|
||
const headZ = body[0]?.[3] ?? 0;
|
||
const rWZ = body[R_WRIST]?.[3] ?? 0, lWZ = body[L_WRIST]?.[3] ?? 0;
|
||
let best = null, bestD = 14;
|
||
const probe = (pts, group, defZ) => {
|
||
if (!pts || this.hiddenGroups.has(group)) return;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const pt = pts[i]; if (!pt) continue;
|
||
if ((pt[2] ?? 1) < 0.01) continue;
|
||
const label = `${group}_${i}`;
|
||
if (this.hiddenLayers.has(label)) continue;
|
||
const z = pt[3] ?? defZ;
|
||
let sx, sy;
|
||
if (axis === "top") {
|
||
sx = cx + (pt[0] / pW - 0.5) * scale;
|
||
sy = cy + z / Z_RANGE * (scale * 0.5);
|
||
} else {
|
||
sx = cx - z / Z_RANGE * (scale * 0.5);
|
||
sy = cy + (pt[1] / pH - 0.5) * scale;
|
||
}
|
||
const d = Math.hypot(lx - sx, ly - sy);
|
||
if (d < bestD) { bestD = d; best = { group, index:i, label }; }
|
||
}
|
||
};
|
||
probe(body, "body", 0);
|
||
probe(fd.rhand,"rhand", rWZ);
|
||
probe(fd.lhand,"lhand", lWZ);
|
||
probe(fd.face, "face", headZ);
|
||
// NLF joints — use EFFECTIVE NLF data so edited positions are clickable
|
||
if (this._experimentalMode && this._nlfAlpha > 0 && this._nlfData) {
|
||
const nlfPts = fd?.nlf_body;
|
||
if (nlfPts) {
|
||
for (let i = 0; i < nlfPts.length; i++) {
|
||
const p = nlfPts[i]; if (!p || (p[2] ?? 0) < 0.05) continue;
|
||
const z = p[3] ?? 0;
|
||
let px, py;
|
||
if (axis === "top") { px = cx + (p[0] / pW - 0.5) * scale; py = cy + z / Z_RANGE * (scale * 0.5); }
|
||
else { px = cx - z / Z_RANGE * (scale * 0.5); py = cy + (p[1] / pH - 0.5) * scale; }
|
||
const d = Math.hypot(lx - px, ly - py);
|
||
if (d < bestD) { bestD = d; best = { group: "nlf_body", index: i, label: `nlf_body_${i}` }; }
|
||
}
|
||
}
|
||
}
|
||
// Camera aim point — draggable in ortho views
|
||
if (this._hasCameraKeyframes()) {
|
||
const cam = this._getInterpolatedCamera(fi);
|
||
const aimSX = cx + cam.x * scale;
|
||
const aimSY = cy + cam.y * scale;
|
||
if (Math.hypot(lx - aimSX, ly - aimSY) < bestD || (!best && Math.hypot(lx - aimSX, ly - aimSY) < 14))
|
||
return { group:"camera", index:-1, label:"camera_aim", isCameraAim:true };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Mouse events
|
||
// -----------------------------------------------------------------------
|
||
_onCanvasMouseDown(e) {
|
||
e.preventDefault();
|
||
this._hideSegmentPopup();
|
||
const canvasRect = this.canvas.getBoundingClientRect();
|
||
const cx = e.clientX - canvasRect.left, cy = e.clientY - canvasRect.top;
|
||
const cw = this.canvas.width, ch = this.canvas.height;
|
||
|
||
// Find which panel was clicked
|
||
const panelRects = this._getPanelRects(cw, ch);
|
||
let clickedPanel = -1, pRect = null;
|
||
for (let i = 0; i < panelRects.length; i++) {
|
||
const r = panelRects[i];
|
||
if (cx >= r.x && cx < r.x + r.w && cy >= r.y && cy < r.y + r.h) {
|
||
clickedPanel = i; pRect = r; break;
|
||
}
|
||
}
|
||
if (clickedPanel < 0) return;
|
||
|
||
// Save previous active panel state and load new
|
||
this._savePanelState(this._activePanel);
|
||
this._activePanel = clickedPanel;
|
||
this._loadPanelState(clickedPanel);
|
||
this.cameraView = this._panelViews[clickedPanel] ?? "front";
|
||
this._dragPanel = clickedPanel;
|
||
this._dragPanelRect = pRect;
|
||
|
||
const lx = cx - pRect.x;
|
||
const ly = cy - pRect.y;
|
||
|
||
// Panel header click — open view-picker dropdown
|
||
if (ly < PANEL_HEADER_H) {
|
||
this._showViewMenu(clickedPanel, e.clientX, e.clientY);
|
||
return;
|
||
}
|
||
|
||
// Content-local coordinates (header removed)
|
||
const clx = lx, cly = ly - PANEL_HEADER_H;
|
||
const contentW = pRect.w, contentH = pRect.h - PANEL_HEADER_H;
|
||
const view = this._panelViews[clickedPanel] ?? "front";
|
||
|
||
// Middle-mouse or Alt+left-drag → pan
|
||
if (e.button === 1 || (e.button === 0 && e.altKey && view !== "orbit")) {
|
||
this._vpPanDrag = { startX: cx, startY: cy, startPanX: this.vpPanX, startPanY: this.vpPanY };
|
||
return;
|
||
}
|
||
|
||
// Camera view — Lock Camera to View button or Ortho/Persp toggle or locked-orbit drag
|
||
if (view === "camera") {
|
||
const btn = this._camLockBtnRect;
|
||
if (btn && clx >= btn.x && clx < btn.x + btn.w && cly >= btn.y && cly < btn.y + btn.h) {
|
||
this._camLocked = !this._camLocked;
|
||
this._renderFrame(this.currentFrame);
|
||
return;
|
||
}
|
||
const orthBtn = this._camOrthoBtnRect;
|
||
if (orthBtn && clx >= orthBtn.x && clx < orthBtn.x + orthBtn.w && cly >= orthBtn.y && cly < orthBtn.y + orthBtn.h) {
|
||
this._camOrtho = !this._camOrtho;
|
||
this._renderFrame(this.currentFrame);
|
||
return;
|
||
}
|
||
if (this._camLocked && e.button === 0) {
|
||
const cam = this._getInterpolatedCamera(this.currentFrame);
|
||
if (e.shiftKey) {
|
||
// Shift+drag = lateral pan (writes cam_x / cam_y KFs)
|
||
this._orbitDrag = { startX: clx, startY: cly, startCamX: cam.x, startCamY: cam.y, isCamPan: true };
|
||
} else {
|
||
// Regular drag = orbit rotation (writes cam_pan / cam_tilt KFs)
|
||
this._orbitDrag = { startX: clx, startY: cly, startYaw: cam.pan, startPitch: cam.tilt, isCamLocked: true };
|
||
}
|
||
}
|
||
return; // Camera view: no joint hit-tests (orbit render doesn't match front-view coords)
|
||
}
|
||
|
||
// Orbit view
|
||
if (view === "orbit") {
|
||
const hit = this._hitTestOrbit(clx, cly, contentW, contentH);
|
||
if (hit) {
|
||
if (hit.group === "nlf_body") this.selectedNlfJoint = { group: hit.group, index: hit.index, label: hit.label };
|
||
else this.selectedNlfJoint = null;
|
||
if (e.shiftKey) this.selectedJoints.add(hit.label);
|
||
else if (!this.selectedJoints.has(hit.label)) { this.selectedJoints.clear(); this.selectedJoints.add(hit.label); }
|
||
this.selectedJoint = hit;
|
||
this._updateJointInfo(); this._renderFrame(this.currentFrame); this._refreshTimeline();
|
||
if (this.lockedLayers.has(hit.label)) return;
|
||
this._armUndo();
|
||
const orbitMulti = this.selectedJoints.size > 1 && this.selectedJoints.has(hit.label);
|
||
this.dragJoint = { ...hit, isOrbit: true, startCanvasX: clx, startCanvasY: cly, multiDrag: orbitMulti };
|
||
} else {
|
||
this._orbitDrag = { startX: clx, startY: cly, startYaw: this.orbitYaw, startPitch: this.orbitPitch };
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Ortho views — joint hit or pan
|
||
if (view === "top" || view === "side") {
|
||
const hit = this._hitTestOrtho(clx, cly, contentW, contentH, view);
|
||
if (hit?.isCameraAim) {
|
||
const cam = this._getInterpolatedCamera(this.currentFrame);
|
||
const scale = Math.min(contentW, contentH) * (1 - 0.10 * 2) * this.vpZoom;
|
||
this._armUndo();
|
||
this._camAimDrag = { startLX: clx, startLY: cly, startCamX: cam.x, startCamY: cam.y,
|
||
view, scale };
|
||
} else if (hit) {
|
||
if (hit.group === "nlf_body") this.selectedNlfJoint = { group: hit.group, index: hit.index, label: hit.label };
|
||
else this.selectedNlfJoint = null;
|
||
if (e.shiftKey) this.selectedJoints.add(hit.label);
|
||
else { this.selectedJoints.clear(); this.selectedJoints.add(hit.label); }
|
||
this.selectedJoint = hit;
|
||
this._updateJointInfo(); this._renderFrame(this.currentFrame); this._refreshTimeline();
|
||
if (this.lockedLayers.has(hit.label)) return;
|
||
this._armUndo();
|
||
this.dragJoint = { ...hit, isOrthoView: view, startLX: clx, startLY: cly,
|
||
orthoVW: contentW, orthoVH: contentH };
|
||
} else if (e.button === 0) {
|
||
if (!e.shiftKey) this.selectedJoints.clear();
|
||
this._vpPanDrag = { startX: cx, startY: cy, startPanX: this.vpPanX, startPanY: this.vpPanY };
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Front / back / camera view
|
||
// Move gizmo hit test (XY axis arrows) — takes priority over joint hit
|
||
const mgHit = this._hitTestMoveGizmo(cx, cy);
|
||
if (mgHit && this.selectedJoint && !this.lockedLayers.has(this.selectedJoint.label)) {
|
||
this._armUndo();
|
||
const { group: _mg, index: _mi, label: _ml } = this.selectedJoint;
|
||
this._moveGizmoDrag = { axis: mgHit, group: _mg, index: _mi, label: _ml };
|
||
return;
|
||
}
|
||
|
||
const gHit = ROTATION_ENABLED ? this._hitTestGizmo(cx, cy) : null;
|
||
if (gHit) {
|
||
const gc = this.gizmoCenter;
|
||
this.dragGizmo = { ...gHit, lastMX: cx, lastMY: cy, lastAngle: Math.atan2(cy - gc.y, cx - gc.x) };
|
||
return;
|
||
}
|
||
|
||
const hit = this._hitTest(clx, cly);
|
||
if (hit?.isCameraAim) {
|
||
// Drag camera aim point in front/back view to reposition cam_x/cam_y
|
||
const cam = this._getInterpolatedCamera(this.currentFrame);
|
||
const { sx, sy } = this._getFrontTransform(contentW, contentH);
|
||
this._armUndo();
|
||
this._camAimDrag = { startLX: clx, startLY: cly, startCamX: cam.x, startCamY: cam.y,
|
||
view, sx, sy };
|
||
return;
|
||
}
|
||
if (hit) {
|
||
// NLF hits also go through the normal drag path now (NLF is editable).
|
||
// Track the NLF selection so the graph editor / overlay highlight know about it.
|
||
if (hit.group === "nlf_body") this.selectedNlfJoint = { group: hit.group, index: hit.index, label: hit.label };
|
||
else this.selectedNlfJoint = null;
|
||
if (e.shiftKey) this.selectedJoints.add(hit.label);
|
||
else if (!this.selectedJoints.has(hit.label)) { this.selectedJoints.clear(); this.selectedJoints.add(hit.label); }
|
||
this.selectedJoint = hit;
|
||
this._updateJointInfo(); this._renderFrame(this.currentFrame); this._refreshTimeline();
|
||
if (this.lockedLayers.has(hit.label)) return;
|
||
|
||
const fi = this.currentFrame, fd = this._getEffectiveFrame(fi);
|
||
this._armUndo();
|
||
if (this.selectedJoints.size > 1 && this.selectedJoints.has(hit.label)) {
|
||
const { sx, sy, ox, oy } = this._getFrontTransform(contentW, contentH);
|
||
const pose = this._canvasToPose(clx, cly, sx, sy, ox, oy);
|
||
this.dragJoint = { ...hit, multiDrag: true, startPoseX: pose.x, startPoseY: pose.y,
|
||
origPositions: this._getSelectionOrigPositions(fi, fd) };
|
||
} else {
|
||
this.dragJoint = { ...hit };
|
||
}
|
||
return;
|
||
}
|
||
|
||
// No joint hit → rubber-band selection (in panel content-local coords)
|
||
if (!e.shiftKey) this.selectedJoints.clear();
|
||
this._vpSelectRect = { startX: clx, startY: cly, curX: clx, curY: cly };
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
/** Get centroid of selectedJoints in pose space. */
|
||
_getSelectionCentroid(fi, fd) {
|
||
let sx=0, sy=0, n=0;
|
||
for (const lbl of this.selectedJoints) {
|
||
const pt=this._getJointPosePos(lbl, fi, fd);
|
||
if (pt) { sx+=pt[0]; sy+=pt[1]; n++; }
|
||
}
|
||
return n>0 ? {px:sx/n, py:sy/n} : null;
|
||
}
|
||
/** Get original pose positions for all selectedJoints. */
|
||
_getSelectionOrigPositions(fi, fd) {
|
||
const map={};
|
||
for (const lbl of this.selectedJoints) {
|
||
const pt=this._getJointPosePos(lbl, fi, fd);
|
||
if (pt) map[lbl]={x:pt[0], y:pt[1]};
|
||
}
|
||
return map;
|
||
}
|
||
/** Get resolved [x,y] pose position for a joint label at fi. */
|
||
_getJointPosePos(label, fi, fd) {
|
||
const ov=this.overrides[fi]?.[label];
|
||
if (ov) return ov;
|
||
const { group: grp, index: ki } = _splitLabel(label);
|
||
const pts = _grpPts(fd, grp);
|
||
return pts?.[ki] ?? null;
|
||
}
|
||
|
||
_onCanvasMouseMove(e) {
|
||
const canvasRect = this.canvas.getBoundingClientRect();
|
||
const cx = e.clientX - canvasRect.left, cy = e.clientY - canvasRect.top;
|
||
const pRect = this._dragPanelRect;
|
||
// Local content-area coordinates for the drag panel
|
||
const localX = pRect ? cx - pRect.x : cx;
|
||
const localY = pRect ? cy - pRect.y - PANEL_HEADER_H : cy;
|
||
|
||
// Viewport rubber-band selection (stored in panel content-local coords)
|
||
if (this._vpSelectRect) {
|
||
this._vpSelectRect.curX = localX; this._vpSelectRect.curY = localY;
|
||
this._renderFrame(this.currentFrame);
|
||
return;
|
||
}
|
||
|
||
// Pan drag — uses absolute canvas delta for vpPanX/Y
|
||
if (this._vpPanDrag) {
|
||
e.preventDefault();
|
||
this.vpPanX = this._vpPanDrag.startPanX + (cx - this._vpPanDrag.startX);
|
||
this.vpPanY = this._vpPanDrag.startPanY + (cy - this._vpPanDrag.startY);
|
||
this._savePanelState(this._dragPanel);
|
||
this._renderFrame(this.currentFrame);
|
||
return;
|
||
}
|
||
|
||
// Camera aim-point drag in front/top/side views → cam_x / cam_y
|
||
if (this._camAimDrag) {
|
||
e.preventDefault();
|
||
const d = this._camAimDrag;
|
||
const dx = localX - d.startLX, dy = localY - d.startLY;
|
||
let newX = d.startCamX, newY = d.startCamY;
|
||
if (d.view === "front" || d.view === "back") {
|
||
newX = Math.max(-2, Math.min(2, d.startCamX + dx / (this.poseW * d.sx)));
|
||
newY = Math.max(-2, Math.min(2, d.startCamY + dy / (this.poseH * d.sy)));
|
||
} else if (d.view === "top") {
|
||
newX = Math.max(-2, Math.min(2, d.startCamX + dx / d.scale));
|
||
// Y unchanged in top view (top view X = scene X, top view Y = scene Z)
|
||
} else { // side
|
||
newY = Math.max(-2, Math.min(2, d.startCamY + dy / d.scale));
|
||
// X unchanged in side view (side view Y = scene Y)
|
||
}
|
||
this._camLockPreview = { ...(this._camLockPreview ?? {}), x: newX, y: newY };
|
||
this._renderFrame(this.currentFrame);
|
||
return;
|
||
}
|
||
|
||
// Orbit rotation drag (startX/Y stored in panel content-local)
|
||
if (this._orbitDrag) {
|
||
e.preventDefault();
|
||
const dx = localX - this._orbitDrag.startX, dy = localY - this._orbitDrag.startY;
|
||
if (this._orbitDrag.isCamPan) {
|
||
// Shift+drag in camera view — lateral pan → cam_x / cam_y (live preview)
|
||
const pRect2 = this._dragPanelRect;
|
||
const cW = pRect2 ? pRect2.w : this.canvas.width;
|
||
const cH = pRect2 ? pRect2.h - PANEL_HEADER_H : this.canvas.height;
|
||
const orbitScale = Math.min(cW / this.poseW, cH / this.poseH) * 0.82 * this.orbitZoom;
|
||
const newX = Math.max(-2, Math.min(2, this._orbitDrag.startCamX - dx / (this.poseW * orbitScale)));
|
||
const newY = Math.max(-2, Math.min(2, this._orbitDrag.startCamY - dy / (this.poseH * orbitScale)));
|
||
this._camLockPreview = { ...(this._camLockPreview ?? {}), x: newX, y: newY };
|
||
this._renderFrame(this.currentFrame);
|
||
return;
|
||
}
|
||
const newYaw = this._orbitDrag.startYaw + dx * 0.4;
|
||
const newPitch = Math.max(-89, Math.min(89, this._orbitDrag.startPitch + dy * 0.4));
|
||
if (this._orbitDrag.isCamLocked) {
|
||
// Live preview only — KF is committed on mouseUp to avoid KF spam
|
||
this._camLockPreview = { pan: newYaw, tilt: newPitch };
|
||
this._renderFrame(this.currentFrame);
|
||
} else {
|
||
this.orbitYaw = newYaw;
|
||
this.orbitPitch = newPitch;
|
||
this._savePanelState(this._dragPanel);
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Move gizmo axis-constrained drag
|
||
if (this._moveGizmoDrag) {
|
||
e.preventDefault();
|
||
const { axis, group, index, label } = this._moveGizmoDrag;
|
||
const fi = this.currentFrame;
|
||
const vWg = this._activeFrontViewW(), vHg = this._activeFrontViewH();
|
||
const { sx: gsx, sy: gsy, ox: gox, oy: goy } = this._getFrontTransform(vWg, vHg);
|
||
const gPose = this._canvasToPose(localX, localY, gsx, gsy, gox, goy);
|
||
this._lazyPushUndo();
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const fd2g = this._getEffectiveFrame(fi);
|
||
const pts2g = _grpPts(fd2g, group);
|
||
const cur = this.overrides[fi][label] || (pts2g?.[index] ? [...pts2g[index]] : [0.5, 0.5, 1, null]);
|
||
const conf = cur[2] ?? 1.0, z = cur[3] ?? null;
|
||
if (axis === "x") {
|
||
this.overrides[fi][label] = [gPose.x, cur[1], conf, z];
|
||
if (pts2g?.[index]) pts2g[index][0] = gPose.x;
|
||
} else {
|
||
this.overrides[fi][label] = [cur[0], gPose.y, conf, z];
|
||
if (pts2g?.[index]) pts2g[index][1] = gPose.y;
|
||
}
|
||
this._updateJointInfo(); this._renderFrame(fi);
|
||
return;
|
||
}
|
||
|
||
if (!this.dragJoint && !this.dragGizmo) return;
|
||
e.preventDefault();
|
||
const fi = this.currentFrame;
|
||
|
||
if (this.dragGizmo) {
|
||
const { axis, side } = this.dragGizmo;
|
||
let delta;
|
||
if (axis === "z") {
|
||
const gc = this.gizmoCenter;
|
||
const na = Math.atan2(cy - gc.y, cx - gc.x);
|
||
let da = (na - this.dragGizmo.lastAngle) * 180 / Math.PI;
|
||
while (da > 180) da -= 360; while (da < -180) da += 360;
|
||
this.dragGizmo.lastAngle = na; delta = da;
|
||
} else if (axis === "x") { delta = -(cy - this.dragGizmo.lastMY) * 0.5; this.dragGizmo.lastMY = cy; }
|
||
else { delta = (cx - this.dragGizmo.lastMX) * 0.5; }
|
||
this.dragGizmo.lastMX = cx;
|
||
if (Math.abs(delta) > 0.01) this._rotateHand(side, axis, delta);
|
||
return;
|
||
}
|
||
|
||
const { group, index, label } = this.dragJoint;
|
||
|
||
// Orbit-view drag → free 3D movement via camera-space unproject
|
||
if (this.dragJoint.isOrbit) {
|
||
const contentW = pRect ? pRect.w : this.canvas.width;
|
||
const contentH = pRect ? pRect.h - PANEL_HEADER_H : this.canvas.height;
|
||
const dscr_x = localX - this.dragJoint.startCanvasX;
|
||
const dscr_y = localY - this.dragJoint.startCanvasY;
|
||
this.dragJoint.startCanvasX = localX; this.dragJoint.startCanvasY = localY;
|
||
if (dscr_x === 0 && dscr_y === 0) { this._renderFrame(fi); return; }
|
||
this._lazyPushUndo();
|
||
const scale = Math.min(contentW / this.poseW, contentH / this.poseH) * 0.82 * this.orbitZoom;
|
||
const Z_SCALE = this.poseW * 0.35;
|
||
const M = this._getOrbitMatrix();
|
||
let dpx = dscr_x / scale * M.m00 + dscr_y / scale * M.m01;
|
||
let dpy = dscr_x / scale * M.m10 + dscr_y / scale * M.m11;
|
||
let dpz = (dscr_x / scale * M.m20 + dscr_y / scale * M.m21) / Z_SCALE;
|
||
if (dpx !== 0 || dpy !== 0 || dpz !== 0) {
|
||
const fd2 = this._getEffectiveFrame(fi);
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const labelsToMove = this.dragJoint.multiDrag
|
||
? [...this.selectedJoints].filter(l => !this.lockedLayers.has(l))
|
||
: [label];
|
||
for (const lbl of labelsToMove) {
|
||
const { group: grp, index: ki } = _splitLabel(lbl);
|
||
const pts2 = _grpPts(fd2, grp);
|
||
if (dpx !== 0 || dpy !== 0) {
|
||
const cur = this.overrides[fi]?.[lbl] || (pts2?.[ki] ? [...pts2[ki]] : [0,0,1,null]);
|
||
this.overrides[fi][lbl] = [cur[0]+dpx, cur[1]+dpy, cur[2]??1.0, cur[3]??null];
|
||
if (grp === "body") this._syncNlfJoint(fi, lbl, this.overrides[fi][lbl][0], this.overrides[fi][lbl][1]);
|
||
}
|
||
if (dpz !== 0) {
|
||
if (!this.overrides[fi][lbl]) this.overrides[fi][lbl] = [0, 0, 1, null];
|
||
const ov = this.overrides[fi][lbl];
|
||
if (ov.length < 4) ov.push(null);
|
||
ov[3] = parseFloat(((ov[3]??0)+dpz).toFixed(3));
|
||
}
|
||
}
|
||
}
|
||
this._updateJointInfo(); this._renderFrame(fi);
|
||
return;
|
||
}
|
||
|
||
// Ortho-view drag (top/side) → edit X/Y pose and Z depth
|
||
if (this.dragJoint.isOrthoView) {
|
||
const axis = this.dragJoint.isOrthoView;
|
||
const vW = this.dragJoint.orthoVW, vH = this.dragJoint.orthoVH;
|
||
const MARGIN = 0.10;
|
||
const scale = Math.min(vW, vH) * (1 - MARGIN * 2) * this.vpZoom;
|
||
const Z_RANGE = 1.5;
|
||
const dscr_x = localX - this.dragJoint.startLX;
|
||
const dscr_y = localY - this.dragJoint.startLY;
|
||
this.dragJoint.startLX = localX; this.dragJoint.startLY = localY;
|
||
if (dscr_x === 0 && dscr_y === 0) { this._renderFrame(fi); return; }
|
||
this._lazyPushUndo();
|
||
const fd2 = this._getEffectiveFrame(fi);
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const { group: grp, index: ki } = _splitLabel(label);
|
||
const pts2 = _grpPts(fd2, grp);
|
||
const cur = this.overrides[fi]?.[label] || (pts2?.[ki] ? [...pts2[ki]] : [0.5, 0.5, 1, 0]);
|
||
const curZ = cur[3] ?? (pts2?.[ki]?.[3] ?? 0);
|
||
if (axis === "top") {
|
||
// Horizontal → pose X; Vertical → Z depth
|
||
const dpx = dscr_x / scale;
|
||
const dz = dscr_y * Z_RANGE / (scale * 0.5);
|
||
this.overrides[fi][label] = [cur[0] + dpx, cur[1], cur[2] ?? 1.0, parseFloat((curZ + dz).toFixed(3))];
|
||
} else {
|
||
// Side: Horizontal → Z depth (negated); Vertical → pose Y
|
||
const dz = -dscr_x * Z_RANGE / (scale * 0.5);
|
||
const dpy = dscr_y / scale;
|
||
this.overrides[fi][label] = [cur[0], cur[1] + dpy, cur[2] ?? 1.0, parseFloat((curZ + dz).toFixed(3))];
|
||
}
|
||
this._updateJointInfo(); this._renderFrame(fi);
|
||
return;
|
||
}
|
||
|
||
// Front-view joint drag — use panel content-local coords
|
||
const vW = this._activeFrontViewW(), vH = this._activeFrontViewH();
|
||
const {sx,sy,ox,oy} = this._getFrontTransform(vW, vH);
|
||
const pose = this._canvasToPose(localX, localY, sx, sy, ox, oy);
|
||
this._lazyPushUndo();
|
||
|
||
if (this._inRefFrameMode) {
|
||
// Ref frame mode: write to refFrameOverrides only, no IK, no camera
|
||
if (this.dragJoint.multiDrag) {
|
||
const delta={x:pose.x-this.dragJoint.startPoseX, y:pose.y-this.dragJoint.startPoseY};
|
||
for (const [lbl, orig] of Object.entries(this.dragJoint.origPositions)) {
|
||
const existing = this.refFrameOverrides[lbl];
|
||
const conf=(Array.isArray(existing)&&existing[2]!==undefined)?existing[2]:1.0;
|
||
const z=Array.isArray(existing)?existing[3]??null:null;
|
||
this.refFrameOverrides[lbl]=[orig.x+delta.x, orig.y+delta.y, conf, z];
|
||
}
|
||
} else {
|
||
const existing = this.refFrameOverrides[label];
|
||
const conf=(Array.isArray(existing)&&existing[2]!==undefined)?existing[2]:1.0;
|
||
const z=existing?.[3]??null;
|
||
this.refFrameOverrides[label]=[pose.x,pose.y,conf,z];
|
||
}
|
||
} else if (this.dragJoint.multiDrag) {
|
||
// Move all selected joints together
|
||
const delta={x:pose.x-this.dragJoint.startPoseX, y:pose.y-this.dragJoint.startPoseY};
|
||
if (!this.overrides[fi]) this.overrides[fi]={};
|
||
for (const [lbl, orig] of Object.entries(this.dragJoint.origPositions)) {
|
||
const existing=this.overrides[fi][lbl];
|
||
const conf=(Array.isArray(existing)&&existing[2]!==undefined)?existing[2]:1.0;
|
||
const z=Array.isArray(existing)?existing[3]??null:null;
|
||
this.overrides[fi][lbl]=[orig.x+delta.x, orig.y+delta.y, conf, z];
|
||
}
|
||
} else {
|
||
// Move single joint
|
||
if (!this.overrides[fi]) this.overrides[fi]={};
|
||
|
||
// IK: capture delta before overwriting position
|
||
let _ikDeltaX = 0, _ikDeltaY = 0;
|
||
const _isRWrist = group === "body" && index === R_WRIST && this.handIkMode.rhand;
|
||
const _isLWrist = group === "body" && index === L_WRIST && this.handIkMode.lhand;
|
||
const _isRPalm = group === "rhand" && index === 0 && this.handIkMode.rhand;
|
||
const _isLPalm = group === "lhand" && index === 0 && this.handIkMode.lhand;
|
||
if (_isRWrist || _isLWrist || _isRPalm || _isLPalm) {
|
||
const prevOv = this.overrides[fi]?.[label];
|
||
const rawArr = group === "body" ? this.frames[fi]?.body
|
||
: group === "rhand" ? this.frames[fi]?.rhand
|
||
: this.frames[fi]?.lhand;
|
||
const rawPt = rawArr?.[index];
|
||
const oldX = prevOv?.[0] ?? rawPt?.[0] ?? pose.x;
|
||
const oldY = prevOv?.[1] ?? rawPt?.[1] ?? pose.y;
|
||
_ikDeltaX = pose.x - oldX;
|
||
_ikDeltaY = pose.y - oldY;
|
||
}
|
||
|
||
const existing=this.overrides[fi][label];
|
||
const conf=(Array.isArray(existing)&&existing[2]!==undefined)?existing[2]:1.0;
|
||
const z=existing?.[3]??null;
|
||
this.overrides[fi][label]=[pose.x,pose.y,conf,z];
|
||
const fd=this.frames[fi];
|
||
if (fd) {
|
||
const pts = _grpPts(fd, group);
|
||
if (pts?.[index]){pts[index][0]=pose.x;pts[index][1]=pose.y;}
|
||
}
|
||
if (group === "body") this._syncNlfJoint(fi, label, pose.x, pose.y);
|
||
|
||
// IK: propagate delta to linked joints
|
||
if (_isRWrist) this._translateHand(fi, "rhand", _ikDeltaX, _ikDeltaY);
|
||
if (_isLWrist) this._translateHand(fi, "lhand", _ikDeltaX, _ikDeltaY);
|
||
if (_isRPalm || _isLPalm) {
|
||
const handGroup = _isRPalm ? "rhand" : "lhand";
|
||
const bWristIdx = _isRPalm ? R_WRIST : L_WRIST;
|
||
// Translate fingers (skip joint 0, already written by drag)
|
||
this._translateHand(fi, handGroup, _ikDeltaX, _ikDeltaY, 1);
|
||
// Translate the body wrist joint
|
||
if (_ikDeltaX !== 0 || _ikDeltaY !== 0) {
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const bLbl = `body_${bWristIdx}`;
|
||
const bOv = this.overrides[fi][bLbl];
|
||
const bRaw = this.frames[fi]?.body?.[bWristIdx];
|
||
const bBase = bOv || bRaw;
|
||
if (bBase) {
|
||
this.overrides[fi][bLbl] = [bBase[0] + _ikDeltaX, bBase[1] + _ikDeltaY, bBase[2] ?? 1.0];
|
||
if (bRaw) { bRaw[0] = this.overrides[fi][bLbl][0]; bRaw[1] = this.overrides[fi][bLbl][1]; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
this._updateJointInfo(); this._renderFrame(fi); this._renderTrack();
|
||
}
|
||
|
||
_onCanvasMouseUp(e) {
|
||
if (this._vpSelectRect) {
|
||
const sel = this._vpSelectRect;
|
||
this._vpSelectRect = null;
|
||
const vW = this._activeFrontViewW(), vH = this._activeFrontViewH();
|
||
const { sx, sy, ox, oy } = this._getFrontTransform(vW, vH);
|
||
const minCx = Math.min(sel.startX,sel.curX), maxCx = Math.max(sel.startX,sel.curX);
|
||
const minCy = Math.min(sel.startY,sel.curY), maxCy = Math.max(sel.startY,sel.curY);
|
||
if (!e.shiftKey) this.selectedJoints.clear();
|
||
const fd = this._getEffectiveFrame(this.currentFrame);
|
||
if (fd) {
|
||
for (const {group,pts} of [{group:"body",pts:fd.body||[]},{group:"rhand",pts:fd.rhand||[]},{group:"lhand",pts:fd.lhand||[]},{group:"face",pts:fd.face||[]}]) {
|
||
if (!pts || this.hiddenGroups.has(group)) continue;
|
||
for (let i = 0; i < pts.length; i++) {
|
||
const pt = pts[i]; if (!pt) continue;
|
||
if ((pt[2]??1) < 0.01) continue;
|
||
const c = this._poseToCanvas(pt[0],pt[1],sx,sy,ox,oy);
|
||
if (c.x>=minCx&&c.x<=maxCx&&c.y>=minCy&&c.y<=maxCy)
|
||
this.selectedJoints.add(`${group}_${i}`);
|
||
}
|
||
}
|
||
}
|
||
const last = [...this.selectedJoints].pop();
|
||
if (last) {
|
||
const { group: grp, index: ki } = _splitLabel(last);
|
||
this.selectedJoint = { group:grp, index:ki, label:last };
|
||
this._updateJointInfo();
|
||
}
|
||
this._renderFrame(this.currentFrame); this._refreshTimeline();
|
||
return;
|
||
}
|
||
if (this.dragJoint && !this.autoKeyframe) {
|
||
const fi = this.currentFrame, ovr = this.overrides[fi];
|
||
if (ovr) for (const label of Object.keys(ovr)) this._tempKeys.add(`${fi}::${label}`);
|
||
}
|
||
// Commit camera aim drag (front/ortho views) → cam_x / cam_y KFs
|
||
if (this._camAimDrag && this._camLockPreview) {
|
||
const fi = this.currentFrame;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const ov = this.overrides[fi];
|
||
if (this._camLockPreview.x !== undefined) {
|
||
const prev = ov["cam_x"];
|
||
ov["cam_x"] = [this._camLockPreview.x, prev?.[1]??0, prev?.[2]??1, prev?.[3]??0];
|
||
}
|
||
if (this._camLockPreview.y !== undefined) {
|
||
const prev = ov["cam_y"];
|
||
ov["cam_y"] = [this._camLockPreview.y, prev?.[1]??0, prev?.[2]??1, prev?.[3]??0];
|
||
}
|
||
this._camLockPreview = null;
|
||
this._refreshTimeline();
|
||
}
|
||
this._camAimDrag = null;
|
||
|
||
// Commit locked-camera orbit drag (pan/tilt) or pan drag (x/y) on mouse release
|
||
if (this._orbitDrag && this._camLockPreview) {
|
||
const fi = this.currentFrame;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const ov = this.overrides[fi];
|
||
if (this._orbitDrag.isCamLocked) {
|
||
const prev = ov["cam_pan"], prevT = ov["cam_tilt"];
|
||
ov["cam_pan"] = [this._camLockPreview.pan, prev?.[1]??0, prev?.[2]??1, prev?.[3]??0];
|
||
ov["cam_tilt"] = [this._camLockPreview.tilt, prevT?.[1]??0, prevT?.[2]??1, prevT?.[3]??0];
|
||
}
|
||
if (this._orbitDrag.isCamPan) {
|
||
if (this._camLockPreview.x !== undefined) {
|
||
const prev = ov["cam_x"];
|
||
ov["cam_x"] = [this._camLockPreview.x, prev?.[1]??0, prev?.[2]??1, prev?.[3]??0];
|
||
}
|
||
if (this._camLockPreview.y !== undefined) {
|
||
const prev = ov["cam_y"];
|
||
ov["cam_y"] = [this._camLockPreview.y, prev?.[1]??0, prev?.[2]??1, prev?.[3]??0];
|
||
}
|
||
}
|
||
this._camLockPreview = null;
|
||
this._refreshTimeline();
|
||
}
|
||
this.dragJoint = null; this.dragGizmo = null; this._moveGizmoDrag = null;
|
||
this._vpPanDrag = null; this._orbitDrag = null;
|
||
this._dragPreState = null;
|
||
}
|
||
|
||
_onCanvasWheel(e) {
|
||
e.preventDefault();
|
||
const canvasRect = this.canvas.getBoundingClientRect();
|
||
const cx = e.clientX - canvasRect.left, cy = e.clientY - canvasRect.top;
|
||
const cw = this.canvas.width, ch = this.canvas.height;
|
||
const factor = e.deltaY > 0 ? 1/1.12 : 1.12;
|
||
|
||
// Find which panel the wheel is over
|
||
const panelRects = this._getPanelRects(cw, ch);
|
||
let wheelPanel = this._activePanel, wRect = panelRects[this._activePanel];
|
||
for (let i = 0; i < panelRects.length; i++) {
|
||
const r = panelRects[i];
|
||
if (cx >= r.x && cx < r.x + r.w && cy >= r.y && cy < r.y + r.h) {
|
||
wheelPanel = i; wRect = r; break;
|
||
}
|
||
}
|
||
this._loadPanelState(wheelPanel);
|
||
const view = this._panelViews[wheelPanel] ?? "front";
|
||
const lx = cx - wRect.x, ly = cy - wRect.y - PANEL_HEADER_H;
|
||
const contentW = wRect.w, contentH = wRect.h - PANEL_HEADER_H;
|
||
|
||
if (view === "camera" && this._camLocked) {
|
||
// Dolly: scroll writes cam_z KF — FOV never changed by viewport interaction
|
||
const fi = this.currentFrame;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const prev = this.overrides[fi]["cam_z"];
|
||
const curZ = prev?.[0] ?? 1.0;
|
||
const newZ = Math.max(0.1, Math.min(10, curZ * factor));
|
||
this.overrides[fi]["cam_z"] = [newZ, prev?.[1]??0, prev?.[2]??1, prev?.[3]??0];
|
||
this._savePanelState(wheelPanel);
|
||
if (wheelPanel !== this._activePanel) this._loadPanelState(this._activePanel);
|
||
this._renderFrame(fi);
|
||
return;
|
||
} else if (view === "orbit") {
|
||
this.orbitZoom = Math.max(0.2, Math.min(8, this.orbitZoom * factor));
|
||
} else {
|
||
// Zoom around cursor (front/back/camera/top/side)
|
||
const { sx, ox, oy } = this._getFrontTransform(contentW, contentH);
|
||
const poseX = (lx - ox) / sx, poseY = (ly - oy) / sx;
|
||
this.vpZoom = Math.max(0.1, Math.min(30, this.vpZoom * factor));
|
||
const newBase = Math.min(contentW/this.poseW, contentH/this.poseH) * 0.95;
|
||
const newSx = newBase * this.vpZoom;
|
||
this.vpPanX = lx - poseX*newSx - (contentW - this.poseW*newSx)/2;
|
||
this.vpPanY = ly - poseY*newSx - (contentH - this.poseH*newSx)/2;
|
||
}
|
||
this._savePanelState(wheelPanel);
|
||
// If zoomed panel is not active, reload active panel state for rendering
|
||
if (wheelPanel !== this._activePanel) this._loadPanelState(this._activePanel);
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
_onCanvasContextMenu(e) {
|
||
e.preventDefault();
|
||
const canvasRect = this.canvas.getBoundingClientRect();
|
||
const cx = e.clientX - canvasRect.left, cy = e.clientY - canvasRect.top;
|
||
const view = this._panelViews[this._activePanel] ?? "front";
|
||
if (view === "orbit" || view === "top" || view === "side") return;
|
||
const pRect = this._dragPanelRect ?? this._getPanelRects(this.canvas.width, this.canvas.height)[this._activePanel];
|
||
const lx = cx - (pRect?.x ?? 0), ly = cy - (pRect?.y ?? 0) - PANEL_HEADER_H;
|
||
const hit = this._hitTest(lx, ly);
|
||
if (!hit || hit.group !== "body") return;
|
||
if (this.lockedLayers.has(hit.label)) return;
|
||
const fi = this.currentFrame;
|
||
this._pushUndo();
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const label = hit.label, fd = this.frames[fi], pt = fd?.body?.[hit.index];
|
||
const existing = this.overrides[fi][label];
|
||
if (Array.isArray(existing) && existing[2] === 0) delete this.overrides[fi][label];
|
||
else this.overrides[fi][label] = [pt?.[0]??0, pt?.[1]??0, 0];
|
||
this._renderFrame(fi); this._renderTrack();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Interpolation — per-segment + Catmull-Rom
|
||
// -----------------------------------------------------------------------
|
||
_getKeyframesForJoint(label) {
|
||
const frames = new Set(
|
||
Object.keys(this.overrides).map(Number)
|
||
.filter(fi => this.overrides[fi]?.[label] !== undefined)
|
||
);
|
||
if (label === "body_4" || label === "body_7") {
|
||
for (const ch of ["rx","ry","rz"])
|
||
for (const fi of this._getKeyframesForChannel(label, ch))
|
||
frames.add(fi);
|
||
}
|
||
return [...frames].sort((a,b) => a-b);
|
||
}
|
||
|
||
_getKeyframesForChannel(label, ch) {
|
||
const key=`${label}::${ch}`;
|
||
return Object.keys(this.overrides).map(Number)
|
||
.filter(fi=>this.overrides[fi]?.[key]!==undefined).sort((a,b)=>a-b);
|
||
}
|
||
|
||
// Per-channel keyframe lookup for joint_detail rows (xy=indices 0-1, conf=2, z=3)
|
||
_getKeyframesForDetailChannel(label, detail) {
|
||
const idx = detail === "xy" ? 0 : detail === "conf" ? 2 : 3;
|
||
return Object.keys(this.overrides).map(Number).filter(fi => {
|
||
const ov = this.overrides[fi]?.[label];
|
||
return ov !== undefined && ov[idx] !== null && ov[idx] !== undefined;
|
||
}).sort((a, b) => a - b);
|
||
}
|
||
|
||
_interpolateChannel(label, ch, frameIdx) {
|
||
const key=`${label}::${ch}`;
|
||
let prevFi=-1, nextFi=-1;
|
||
for (let f=frameIdx-1;f>=0;f--)
|
||
if (this.overrides[f]?.[key]!==undefined){prevFi=f;break;}
|
||
for (let f=frameIdx+1;f<this.frameCount;f++)
|
||
if (this.overrides[f]?.[key]!==undefined){nextFi=f;break;}
|
||
if (prevFi===-1&&nextFi===-1) return null;
|
||
if (prevFi===-1) return this.overrides[nextFi][key];
|
||
if (nextFi===-1) return this.overrides[prevFi][key];
|
||
const v0=this.overrides[prevFi][key], v1=this.overrides[nextFi][key];
|
||
const t=(frameIdx-prevFi)/(nextFi-prevFi);
|
||
const mode=this.tweens[prevFi]?.[key]??this.interpolationMode;
|
||
return v0+(v1-v0)*applyEasing(t, mode==="catmull_rom"?"linear":mode);
|
||
}
|
||
|
||
_interpolateJoint(label, frameIdx) {
|
||
let prevFi=-1, nextFi=-1;
|
||
for (let f=frameIdx-1;f>=0;f--)
|
||
if (this.overrides[f]?.[label]!==undefined){prevFi=f;break;}
|
||
for (let f=frameIdx+1;f<this.frameCount;f++)
|
||
if (this.overrides[f]?.[label]!==undefined){nextFi=f;break;}
|
||
|
||
if (prevFi===-1&&nextFi===-1) return null;
|
||
if (prevFi===-1) return [...this.overrides[nextFi][label]];
|
||
if (nextFi===-1) return [...this.overrides[prevFi][label]];
|
||
|
||
const v0=this.overrides[prevFi][label], v1=this.overrides[nextFi][label];
|
||
const t=(frameIdx-prevFi)/(nextFi-prevFi);
|
||
|
||
// Determine mode for this segment (per-segment override or global)
|
||
const mode=this.tweens[prevFi]?.[label] ?? this.interpolationMode;
|
||
|
||
if (mode==="catmull_rom") {
|
||
return this._catmullRomInterp(label, prevFi, nextFi, t);
|
||
}
|
||
|
||
const ti=applyEasing(t, mode);
|
||
const z0=v0[3], z1=v1[3];
|
||
const zi=(z0===null&&z1===null)?null:(z0??0)+((z1??0)-(z0??0))*ti;
|
||
const c0=v0.length>2?v0[2]:1.0, c1=v1.length>2?v1[2]:1.0;
|
||
return [v0[0]+(v1[0]-v0[0])*ti,
|
||
v0[1]+(v1[1]-v0[1])*ti,
|
||
c0+(c1-c0)*ti,
|
||
zi];
|
||
}
|
||
|
||
_catmullRomInterp(label, fi_a, fi_b, t) {
|
||
const kfs=this._getKeyframesForJoint(label);
|
||
const idxA=kfs.indexOf(fi_a);
|
||
const fi_prev=idxA>0?kfs[idxA-1]:null;
|
||
const fi_next=idxA+2<kfs.length?kfs[idxA+2]:null;
|
||
|
||
const ov=(fi)=>fi!==null?this.overrides[fi][label]:null;
|
||
const V_a=ov(fi_a), V_b=ov(fi_b);
|
||
if (!V_a||!V_b) return null;
|
||
|
||
// Ghost endpoints: mirror across the boundary
|
||
const V_prev=fi_prev?ov(fi_prev):[V_a[0]*2-V_b[0],V_a[1]*2-V_b[1]];
|
||
const V_next=fi_next?ov(fi_next):[V_b[0]*2-V_a[0],V_b[1]*2-V_a[1]];
|
||
|
||
const alpha=this.catmullTension;
|
||
const cr=(P0,P1,P2,P3,t)=>alpha*((2*P1)+(-P0+P2)*t+(2*P0-5*P1+4*P2-P3)*t*t+(-P0+3*P1-3*P2+P3)*t*t*t);
|
||
|
||
const allNullZ = [V_prev,V_a,V_b,V_next].every(v=>v[3]===null||v[3]===undefined);
|
||
const zi = allNullZ ? null : cr(V_prev[3]??0,V_a[3]??0,V_b[3]??0,V_next[3]??0,t);
|
||
return [
|
||
cr(V_prev[0],V_a[0],V_b[0],V_next[0],t),
|
||
cr(V_prev[1],V_a[1],V_b[1],V_next[1],t),
|
||
cr(V_prev[2]??1,V_a[2]??1,V_b[2]??1,V_next[2]??1,t),
|
||
zi,
|
||
];
|
||
}
|
||
|
||
_toggleRefFrameMode() {
|
||
this._inRefFrameMode = !this._inRefFrameMode;
|
||
const on = this._inRefFrameMode;
|
||
|
||
this._refFrameBtn.textContent = on ? "✕ Exit Ref Frame" : "⊕ Ref Frame";
|
||
this._refFrameBtn.style.background = on ? "#1a3a1a" : "#1a2a1a";
|
||
this._refFrameBtn.style.color = on ? "#8fcc8f" : "";
|
||
|
||
// Create banner on first use; append to the first viewport container
|
||
if (!this._refFrameBanner) {
|
||
this._refFrameBanner = Object.assign(document.createElement("div"), {
|
||
textContent: "⊕ EDITING REF FRAME — Camera transforms are ignored"
|
||
});
|
||
Object.assign(this._refFrameBanner.style, {
|
||
position: "absolute", top: "38px", left: "50%",
|
||
transform: "translateX(-50%)",
|
||
background: "#0a2a0a", border: "1px solid #4a8a4a", color: "#8fcc8f",
|
||
padding: "4px 16px", borderRadius: "4px", fontSize: "12px",
|
||
zIndex: "100", pointerEvents: "none", whiteSpace: "nowrap",
|
||
});
|
||
const vpWrap = this._viewportContainer || this._mainContainer;
|
||
if (vpWrap) vpWrap.appendChild(this._refFrameBanner);
|
||
}
|
||
this._refFrameBanner.style.display = on ? "block" : "none";
|
||
|
||
// Force scrubber to frame 0 so drag code picks up fi=0
|
||
if (on) this._seekFrame(0);
|
||
else this._renderFrame(this.currentFrame);
|
||
this._refreshTimeline();
|
||
}
|
||
|
||
_getEffectiveFrame(idx) {
|
||
const raw=this.frames[idx]; if (!raw) return null;
|
||
// In ref frame mode, frame 0 uses refFrameOverrides (camera-immune)
|
||
const ovr = (this._inRefFrameMode && idx === 0)
|
||
? this.refFrameOverrides
|
||
: (this.overrides[idx] || {});
|
||
|
||
// Synthesize a 21-point hand from overrides/interpolation when the raw frame
|
||
// has no hand data — lets "Add Hand" work across all frames via interpolation.
|
||
const synthHand = (group) => {
|
||
const hasAny = Object.values(this.overrides).some(
|
||
fo => fo && Object.keys(fo).some(k => k.startsWith(`${group}_`))
|
||
);
|
||
if (!hasAny) return null;
|
||
return Array.from({length:21}, (_,i) => {
|
||
const lbl=`${group}_${i}`, ov=ovr[lbl];
|
||
if (ov) return [ov[0], ov[1], ov[2]??1, ov[3]??null];
|
||
const interp=this._interpolateJoint(lbl, idx);
|
||
return interp ? [interp[0], interp[1], interp[2]??1, interp[3]??null] : [0, 0, 1, null];
|
||
});
|
||
};
|
||
|
||
// Base = the per-frame extracted pose (mocap-style). Overrides/interpolation
|
||
// stack on top as user corrections. Fallback to frame 0 per group when raw
|
||
// is missing that group — lets synthesised hands/face from KFs still render.
|
||
const f0 = this.frames[0];
|
||
const result={
|
||
width:raw.width, height:raw.height,
|
||
body: raw.body?raw.body.map(p=>[...p]):(f0?.body?f0.body.map(p=>[...p]):[]),
|
||
rhand:raw.rhand?raw.rhand.map(p=>[...p]):(f0?.rhand?f0.rhand.map(p=>[...p]):synthHand("rhand")),
|
||
lhand:raw.lhand?raw.lhand.map(p=>[...p]):(f0?.lhand?f0.lhand.map(p=>[...p]):synthHand("lhand")),
|
||
face: raw.face?raw.face.map(p=>[...p]):(f0?.face?f0.face.map(p=>[...p]):null),
|
||
};
|
||
const apply=(group,arr)=>{
|
||
if (!arr) return;
|
||
for (let i=0;i<arr.length;i++) {
|
||
const label=`${group}_${i}`, ov=ovr[label];
|
||
if (ov!==undefined){
|
||
if (ov[0] != null) arr[i][0]=ov[0];
|
||
if (ov[1] != null) arr[i][1]=ov[1];
|
||
if (ov[2] != null) arr[i][2]=ov[2];
|
||
arr[i][3] = ov[3] ?? null;
|
||
} else {
|
||
const interp=this._interpolateJoint(label,idx);
|
||
if(interp){
|
||
if (interp[0] != null) arr[i][0]=interp[0];
|
||
if (interp[1] != null) arr[i][1]=interp[1];
|
||
if (interp[2] != null) arr[i][2]=interp[2];
|
||
arr[i][3] = interp[3] ?? null;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
apply("body",result.body); apply("rhand",result.rhand); apply("lhand",result.lhand); apply("face",result.face);
|
||
|
||
// NLF body — same effective-frame treatment so it can be edited like DWpose.
|
||
// Per-frame dynamic Z normalization: each frame's body always spans [-0.5, +0.5]
|
||
// editor units, preserving the 3D depth shape regardless of camera distance.
|
||
// Z is negated so closer-to-camera = +Z (matches the old read-only overlay).
|
||
const nlfRaw = this._nlfData?.[idx]?.body_op18;
|
||
if (nlfRaw && nlfRaw.length) {
|
||
const zVals = [];
|
||
for (const p of nlfRaw) {
|
||
if (p && (p[2] ?? 0) >= 0.05 && isFinite(p[3])) zVals.push(p[3]);
|
||
}
|
||
const zMean = zVals.length ? zVals.reduce((a, b) => a + b, 0) / zVals.length : 0;
|
||
const zMaxAbs = zVals.length ? Math.max(...zVals.map(z => Math.abs(z - zMean)), 0.001) : 1;
|
||
const zNorm = (z) => (zMean - z) / zMaxAbs * 0.5;
|
||
result.nlf_body = nlfRaw.map(p => {
|
||
if (!p) return [0, 0, 0, null];
|
||
const z = isFinite(p[3]) ? zNorm(p[3]) : null;
|
||
return [p[0], p[1], p[2] ?? 0, z];
|
||
});
|
||
apply("nlf_body", result.nlf_body);
|
||
// Defensive clamp — anything beyond ±1.5 editor units is stale legacy metres-Z.
|
||
for (const r of result.nlf_body) {
|
||
if (r && r[3] != null && (!isFinite(r[3]) || Math.abs(r[3]) > 1.5)) r[3] = 0;
|
||
}
|
||
} else if (this._nlfData) {
|
||
// No raw NLF for this frame — synthesise from overrides + interpolation if any exist
|
||
const hasAny = Object.values(this.overrides).some(
|
||
fo => fo && Object.keys(fo).some(k => k.startsWith("nlf_body_"))
|
||
);
|
||
if (hasAny) {
|
||
result.nlf_body = Array.from({length: N_NLF_BODY}, (_, i) => {
|
||
const lbl = `nlf_body_${i}`, ov = ovr[lbl];
|
||
if (ov) return [ov[0], ov[1], ov[2] ?? 1, ov[3] ?? null];
|
||
const interp = this._interpolateJoint(lbl, idx);
|
||
return interp ? [interp[0], interp[1], interp[2] ?? 1, interp[3] ?? null] : [0, 0, 0, null];
|
||
});
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Keyframe insert / delete
|
||
// -----------------------------------------------------------------------
|
||
_insertKeyframeSelected() {
|
||
// Collect target labels: graphSel (graph tab) → selectedJoints (multi) → selectedJoint
|
||
let labels;
|
||
if (this.activeTab === "graph" && this.graphSel.size > 0) {
|
||
const seen = new Set();
|
||
for (const key of this.graphSel) seen.add(key.split("::")[1]);
|
||
labels = [...seen];
|
||
} else if (this.selectedJoints.size > 0) {
|
||
labels = [...this.selectedJoints];
|
||
} else if (this.selectedJoint) {
|
||
labels = [this.selectedJoint.label];
|
||
} else {
|
||
return;
|
||
}
|
||
this._pushUndo();
|
||
for (const lbl of labels) this._insertKeyframeRaw(lbl, this.currentFrame);
|
||
this._refreshTimeline(); this._renderFrame(this.currentFrame);
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
}
|
||
|
||
_deleteKeyframeSelected() {
|
||
if (this.selKfs.size > 0) { this._deleteSelectedKeyframes(); return; }
|
||
const fi = this.currentFrame;
|
||
if (this.selectedJoints.size > 1) {
|
||
this._pushUndo();
|
||
for (const lbl of this.selectedJoints) this._deleteKeyframeRaw(lbl, fi);
|
||
this._refreshTimeline(); this._renderFrame(fi);
|
||
return;
|
||
}
|
||
if (this.selectedJoint) this._deleteKeyframe(this.selectedJoint.label, fi);
|
||
}
|
||
|
||
_insertKeyframe(label, frameIdx) {
|
||
this._pushUndo();
|
||
this._insertKeyframeRaw(label, frameIdx);
|
||
this._refreshTimeline(); this._renderFrame(frameIdx);
|
||
if (this.activeTab==="graph") this._renderGraphEditor();
|
||
}
|
||
|
||
_insertKeyframeRaw(label, frameIdx) {
|
||
const fd=this._getEffectiveFrame(frameIdx); if (!fd) return;
|
||
const { group, index: ki } = _splitLabel(label);
|
||
const pts = _grpPts(fd, group);
|
||
if (!pts?.[ki]) return;
|
||
if (!this.overrides[frameIdx]) this.overrides[frameIdx]={};
|
||
const existing = this.overrides[frameIdx][label];
|
||
const z = existing?.[3] ?? pts[ki][3] ?? null;
|
||
this.overrides[frameIdx][label] = existing
|
||
? (existing.length < 4 ? [...existing, z] : existing)
|
||
: [pts[ki][0], pts[ki][1], pts[ki][2] ?? 1.0, z];
|
||
this._tempKeys.delete(`${frameIdx}::${label}`);
|
||
// Also keyframe wrist rotation channels if they exist
|
||
const WRIST_SET = new Set(["body_4","body_7"]);
|
||
if (WRIST_SET.has(label)) {
|
||
for (const ch of ["rx","ry","rz"]) {
|
||
const key = `${label}::${ch}`;
|
||
this.overrides[frameIdx][key] = this.overrides[frameIdx][key] ?? 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
_deleteKeyframe(label, frameIdx) {
|
||
this._pushUndo();
|
||
this._deleteKeyframeRaw(label, frameIdx);
|
||
}
|
||
|
||
_deleteKeyframeRaw(label, frameIdx) {
|
||
if (!this.overrides[frameIdx]) return;
|
||
delete this.overrides[frameIdx][label];
|
||
if (Object.keys(this.overrides[frameIdx]).length===0) delete this.overrides[frameIdx];
|
||
// Also clean up segment tween for this frame
|
||
if (this.tweens[frameIdx]) { delete this.tweens[frameIdx][label]; if (Object.keys(this.tweens[frameIdx]).length===0) delete this.tweens[frameIdx]; }
|
||
this._refreshTimeline(); this._renderFrame(frameIdx);
|
||
if (this.activeTab==="graph") this._renderGraphEditor();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Joint info
|
||
// -----------------------------------------------------------------------
|
||
_updateJointInfo() {
|
||
if (!this.selectedJoint){this.jointInfoEl.textContent="No joint selected";return;}
|
||
const {group,index,label}=this.selectedJoint, fi=this.currentFrame;
|
||
const fd=this._getEffectiveFrame(fi);
|
||
const pts = _grpPts(fd, group);
|
||
const pt=pts?.[index];
|
||
const z=pt?.[3]??0;
|
||
const kfs=this._getKeyframesForJoint(label), isKf=this.overrides[fi]?.[label]!==undefined;
|
||
const segMode=this.tweens[fi]?.[label]??null;
|
||
const namePart = group === "body" ? (JOINT_LABELS[index] || index)
|
||
: group === "face" ? (FACE_JOINT_LABELS[index] || `F${index}`)
|
||
: (HAND_JOINT_LABELS[index] || `finger_${index}`);
|
||
this.jointInfoEl.innerHTML=`
|
||
<div style="color:#ffd700;font-weight:bold">${label}</div>
|
||
<div style="color:#aaa">${namePart}</div>
|
||
<div>X: ${pt?.[0].toFixed(1)??"–"} Y: ${pt?.[1].toFixed(1)??"–"}</div>
|
||
<div>Z: ${z.toFixed(3)}</div>
|
||
<div style="color:${isKf?"#ffd700":"#88aaff"}">${isKf?"⬦ Keyframe":"◦ Interpolated"}</div>
|
||
${segMode?`<div style="color:#ffaa44;font-size:10px">Seg: ${segMode}</div>`:""}
|
||
<div style="color:#666;font-size:10px">${kfs.length} keyframe(s)</div>
|
||
`;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Apply / Close
|
||
// -----------------------------------------------------------------------
|
||
async _applyChanges() {
|
||
const state={
|
||
_fingerprint: `${this.frameCount}:${this.poseW}:${this.poseH}:${this._contentHash||""}`,
|
||
overrides: this.overrides,
|
||
tweens: this.tweens,
|
||
smooth_window: this.smoothWindow,
|
||
interpolation: this.interpolationMode,
|
||
catmull_tension: this.catmullTension,
|
||
output_view: this._outputView,
|
||
canvas_w: this._canvasW || 0,
|
||
canvas_h: this._canvasH || 0,
|
||
// UI display state — does not affect workflow output
|
||
experimental_mode: this._experimentalMode,
|
||
panel_layout: this._panelLayout,
|
||
panel_views: [...this._panelViews],
|
||
dwpose_alpha: this._dwposeAlpha ?? 1,
|
||
nlf_alpha: this._nlfAlpha ?? 0.5,
|
||
data_mode: this.dataMode ?? "dwpose",
|
||
ref_frame_overrides: this.refFrameOverrides,
|
||
};
|
||
this._logAction("apply_changes", {
|
||
override_frames: Object.keys(this.overrides).length,
|
||
tween_frames: Object.keys(this.tweens).length,
|
||
smooth_window: this.smoothWindow,
|
||
interpolation: this.interpolationMode,
|
||
output_view: this._outputView,
|
||
});
|
||
try {
|
||
await fetch(`/temporal-editor/state/${this.nodeId}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(state)});
|
||
} catch(e){console.warn("DWPoseTEEditor: state POST failed:",e);}
|
||
const node=app.graph.getNodeById(parseInt(this.nodeId));
|
||
if (node) {
|
||
for (const w of node.widgets||[]) {
|
||
if (w.name==="editor_state_json"){w.value=JSON.stringify(state);break;}
|
||
}
|
||
node.setDirtyCanvas(true,true);
|
||
}
|
||
}
|
||
|
||
_resetCache() {
|
||
const overlay = document.createElement("div");
|
||
Object.assign(overlay.style, {
|
||
position: "fixed", inset: "0", background: "rgba(0,0,0,0.7)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
zIndex: "100001",
|
||
});
|
||
|
||
const box = document.createElement("div");
|
||
Object.assign(box.style, {
|
||
background: "#0e1020", border: "1px solid #2c3352",
|
||
borderRadius: "10px", padding: "28px 32px", minWidth: "340px", maxWidth: "420px",
|
||
color: "#b0bcd4", fontFamily: "'Inter',sans-serif",
|
||
boxShadow: "0 12px 48px rgba(0,0,0,0.9)",
|
||
});
|
||
|
||
const title = document.createElement("div");
|
||
title.textContent = "Reset Cache";
|
||
Object.assign(title.style, { fontSize: "16px", fontWeight: "700", marginBottom: "14px", color: "#d4ddf4" });
|
||
box.appendChild(title);
|
||
|
||
const desc = document.createElement("div");
|
||
desc.textContent = "Clear the server detection cache so the next workflow run re-detects poses from scratch.";
|
||
Object.assign(desc.style, { fontSize: "12px", color: "#7888a8", marginBottom: "20px", lineHeight: "1.5" });
|
||
box.appendChild(desc);
|
||
|
||
const divider = document.createElement("div");
|
||
divider.style.cssText = "border-top:1px solid #334;margin-bottom:16px;";
|
||
box.appendChild(divider);
|
||
|
||
const question = document.createElement("div");
|
||
question.textContent = "Also revert all edits to detected state?";
|
||
Object.assign(question.style, { fontSize: "13px", fontWeight: "bold", color: "#ddc", marginBottom: "8px" });
|
||
box.appendChild(question);
|
||
|
||
const warning = document.createElement("div");
|
||
warning.textContent = "This will permanently delete all keyframes and changes made in the editor. This cannot be undone.";
|
||
Object.assign(warning.style, { fontSize: "11px", color: "#c87", marginBottom: "22px", lineHeight: "1.5" });
|
||
box.appendChild(warning);
|
||
|
||
const btnRow = document.createElement("div");
|
||
btnRow.style.cssText = "display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap;";
|
||
|
||
const mkDialogBtn = (text, bg, border, color, onClick) => {
|
||
const b = document.createElement("button");
|
||
b.textContent = text;
|
||
Object.assign(b.style, {
|
||
background: bg, border: `1px solid ${border}`, color,
|
||
borderRadius: "5px", padding: "7px 14px", cursor: "pointer",
|
||
fontSize: "12px", fontWeight: "bold",
|
||
});
|
||
b.addEventListener("click", onClick);
|
||
b.addEventListener("mouseenter", () => b.style.filter = "brightness(1.2)");
|
||
b.addEventListener("mouseleave", () => b.style.filter = "");
|
||
return b;
|
||
};
|
||
|
||
const cancelBtn = mkDialogBtn("Cancel", "#2a2a3a", "#444", "#aaa", () => overlay.remove());
|
||
|
||
const resetOnlyBtn = mkDialogBtn("Reset Cache Only", "#2a2a1a", "#554", "#cc9",
|
||
async () => {
|
||
overlay.remove();
|
||
try { await fetch(`/temporal-editor/reset-cache/${this.nodeId}`, { method: "POST" }); }
|
||
catch(e) { console.warn("Reset cache failed:", e); }
|
||
this._clearNlfSession();
|
||
});
|
||
|
||
const revertBtn = mkDialogBtn("Yes — Revert to Detection", "#3a0a0a", "#733", "#f99",
|
||
async () => {
|
||
overlay.remove();
|
||
try { await fetch(`/temporal-editor/reset-cache/${this.nodeId}`, { method: "POST" }); }
|
||
catch(e) { console.warn("Reset cache failed:", e); }
|
||
// Clear all user edits and undo history (truly non-undoable)
|
||
this.overrides = {};
|
||
this.tweens = {};
|
||
this._undoStack = [];
|
||
this._redoStack = [];
|
||
// Re-populate overrides from base detection frames so the dopesheet
|
||
// shows the original detected poses as editable keyframes
|
||
for (const [fiStr, frame] of Object.entries(this.frames)) {
|
||
const fi = parseInt(fiStr);
|
||
if (!frame) continue;
|
||
const ov = {};
|
||
if (frame.body) {
|
||
frame.body.forEach((kp, ki) => {
|
||
ov[`body_${ki}`] = [kp[0], kp[1], kp[2], kp[3] ?? 0];
|
||
});
|
||
}
|
||
if (frame.rhand) {
|
||
frame.rhand.forEach((kp, ki) => {
|
||
ov[`rhand_${ki}`] = [kp[0], kp[1], kp[2], kp[3] ?? 0];
|
||
});
|
||
}
|
||
if (frame.lhand) {
|
||
frame.lhand.forEach((kp, ki) => {
|
||
ov[`lhand_${ki}`] = [kp[0], kp[1], kp[2], kp[3] ?? 0];
|
||
});
|
||
}
|
||
if (Object.keys(ov).length > 0) this.overrides[fi] = ov;
|
||
}
|
||
// Re-populate NLF overrides from loaded NLF data using the same
|
||
// per-frame Z normalization applied during initial NLF load
|
||
if (this._nlfData) {
|
||
for (let fi = 0; fi < this._nlfData.length; fi++) {
|
||
const op18 = this._nlfData[fi]?.body_op18;
|
||
if (!op18) continue;
|
||
const zVals = [];
|
||
for (const p of op18)
|
||
if (p && (p[2] ?? 0) >= 0.05 && isFinite(p[3])) zVals.push(p[3]);
|
||
if (!zVals.length) continue;
|
||
const zMean = zVals.reduce((a, b) => a + b, 0) / zVals.length;
|
||
const zMaxAbs = Math.max(...zVals.map(z => Math.abs(z - zMean)), 0.001);
|
||
const zNorm = z => (zMean - z) / zMaxAbs * 0.5;
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
for (let i = 0; i < op18.length; i++) {
|
||
const p = op18[i];
|
||
if (!p || (p[2] ?? 0) < 0.05) continue;
|
||
this.overrides[fi][`nlf_body_${i}`] = [p[0], p[1], p[2] ?? 1,
|
||
isFinite(p[3]) ? zNorm(p[3]) : null];
|
||
}
|
||
}
|
||
}
|
||
this._refreshTimeline();
|
||
this._seekFrame(this.currentFrame);
|
||
});
|
||
|
||
btnRow.append(cancelBtn, resetOnlyBtn, revertBtn);
|
||
box.appendChild(btnRow);
|
||
overlay.appendChild(box);
|
||
overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
_clearNlfSession() {
|
||
this._nlfData = null;
|
||
this._nlfStatus = "idle";
|
||
if (this._nlfStatusEl) this._nlfStatusEl.textContent = "NLF: not loaded";
|
||
this._renderFrame(this.currentFrame);
|
||
}
|
||
|
||
_saveUiState() {
|
||
// Persist UI-only fields to the widget without POSTing to the server
|
||
// (no server round-trip → no workflow trigger). Called automatically on close.
|
||
const node = app.graph.getNodeById(parseInt(this.nodeId));
|
||
if (!node) return;
|
||
for (const w of node.widgets || []) {
|
||
if (w.name !== "editor_state_json") continue;
|
||
let base = {};
|
||
try { base = JSON.parse(w.value || "{}"); } catch(_) {}
|
||
Object.assign(base, {
|
||
experimental_mode: this._experimentalMode,
|
||
panel_layout: this._panelLayout,
|
||
panel_views: [...this._panelViews],
|
||
dwpose_alpha: this._dwposeAlpha ?? 1,
|
||
nlf_alpha: this._nlfAlpha ?? 0.5,
|
||
data_mode: this.dataMode ?? "dwpose",
|
||
});
|
||
w.value = JSON.stringify(base);
|
||
break;
|
||
}
|
||
}
|
||
|
||
close() {
|
||
this._saveUiState();
|
||
this._stopPlayback();
|
||
this._hideSegmentPopup();
|
||
this._ro.disconnect();
|
||
this._roGraph.disconnect();
|
||
window.removeEventListener("keydown",this._onKeyDown);
|
||
if (this._trackDocMove) { document.removeEventListener("mousemove", this._trackDocMove); this._trackDocMove = null; }
|
||
if (this._trackDocUp) { document.removeEventListener("mouseup", this._trackDocUp); this._trackDocUp = null; }
|
||
document.body.removeChild(this.overlay);
|
||
if (this._refFileInput?.parentNode) document.body.removeChild(this._refFileInput);
|
||
if (this._refVideoInput?.parentNode) document.body.removeChild(this._refVideoInput);
|
||
if (this._refSeqInput?.parentNode) document.body.removeChild(this._refSeqInput);
|
||
if (this._refVideo) { this._refVideo.src = ""; this._refVideo = null; }
|
||
if (this._projectFileInput?.parentNode) document.body.removeChild(this._projectFileInput);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// New Scene / Edit Project
|
||
// -----------------------------------------------------------------------
|
||
|
||
/** Default T-pose body keypoints scaled to given canvas dimensions. */
|
||
_defaultBodyPose(w, h) {
|
||
const cx = w / 2;
|
||
return [
|
||
[cx, h*0.130, 1], // 0 NOSE
|
||
[cx, h*0.220, 1], // 1 NECK
|
||
[cx - w*0.130, h*0.260, 1], // 2 R_SHLDR
|
||
[cx - w*0.210, h*0.380, 1], // 3 R_ELBOW
|
||
[cx - w*0.270, h*0.500, 1], // 4 R_WRIST
|
||
[cx + w*0.130, h*0.260, 1], // 5 L_SHLDR
|
||
[cx + w*0.210, h*0.380, 1], // 6 L_ELBOW
|
||
[cx + w*0.270, h*0.500, 1], // 7 L_WRIST
|
||
[cx - w*0.070, h*0.540, 1], // 8 R_HIP
|
||
[cx - w*0.070, h*0.700, 1], // 9 R_KNEE
|
||
[cx - w*0.070, h*0.860, 1], // 10 R_ANKLE
|
||
[cx + w*0.070, h*0.540, 1], // 11 L_HIP
|
||
[cx + w*0.070, h*0.700, 1], // 12 L_KNEE
|
||
[cx + w*0.070, h*0.860, 1], // 13 L_ANKLE
|
||
[cx - w*0.038, h*0.108, 1], // 14 R_EYE
|
||
[cx + w*0.038, h*0.108, 1], // 15 L_EYE
|
||
[cx - w*0.072, h*0.140, 1], // 16 R_EAR
|
||
[cx + w*0.072, h*0.140, 1], // 17 L_EAR
|
||
[cx + w*0.070, h*0.910, 1], // 18 L_TOE
|
||
[cx - w*0.070, h*0.910, 1], // 19 R_TOE
|
||
];
|
||
}
|
||
|
||
/** Default 70-landmark face pose anchored at noseX/noseY (300W convention). */
|
||
_defaultFacePose(noseX, noseY) {
|
||
// Scale face to ~12% of pose height; offsets in unit head-size below.
|
||
const u = (this.poseH || 512) * 0.012;
|
||
const pts = new Array(N_FACE);
|
||
// Jawline 0–16 (chin curve, left ear → chin → right ear)
|
||
for (let i = 0; i < 17; i++) {
|
||
const t = (i - 8) / 8; // -1 .. +1
|
||
pts[i] = [t * 5.5, 6 + (1 - t * t) * 2.0]; // shallow U
|
||
}
|
||
// Right brow 17–21, Left brow 22–26
|
||
for (let i = 0; i < 5; i++) {
|
||
const t = i / 4;
|
||
pts[17 + i] = [-3.6 + t * 2.4, -3.0 - Math.sin(t * Math.PI) * 0.6];
|
||
pts[22 + i] = [ 1.2 + t * 2.4, -3.6 + Math.sin(t * Math.PI) * 0.6];
|
||
}
|
||
// Nose bridge 27–30 + nostrils 31–35
|
||
for (let i = 0; i < 4; i++) pts[27 + i] = [0, -1.8 + i * 0.9];
|
||
for (let i = 0; i < 5; i++) {
|
||
const t = (i - 2) / 2;
|
||
pts[31 + i] = [t * 1.4, 2.0];
|
||
}
|
||
// Right eye 36–41, Left eye 42–47 (6 pts each, ovals)
|
||
for (let i = 0; i < 6; i++) {
|
||
const a = (i / 6) * Math.PI * 2;
|
||
pts[36 + i] = [-2.4 + Math.cos(a) * 1.0, -1.2 + Math.sin(a) * 0.4];
|
||
pts[42 + i] = [ 2.4 + Math.cos(a) * 1.0, -1.2 + Math.sin(a) * 0.4];
|
||
}
|
||
// Outer lips 48–59 (12 pts ellipse)
|
||
for (let i = 0; i < 12; i++) {
|
||
const a = (i / 12) * Math.PI * 2;
|
||
pts[48 + i] = [Math.cos(a) * 2.2, 4.4 + Math.sin(a) * 0.9];
|
||
}
|
||
// Inner lips 60–67 (8 pts smaller ellipse)
|
||
for (let i = 0; i < 8; i++) {
|
||
const a = (i / 8) * Math.PI * 2;
|
||
pts[60 + i] = [Math.cos(a) * 1.4, 4.4 + Math.sin(a) * 0.4];
|
||
}
|
||
// Pupils 68–69 (right, left)
|
||
pts[68] = [-2.4, -1.2];
|
||
pts[69] = [ 2.4, -1.2];
|
||
return pts.map(([dx, dy]) => [noseX + dx * u, noseY + dy * u, 1, null]);
|
||
}
|
||
|
||
/** Default relaxed open-hand pose for 21 keypoints anchored at wristX, wristY. */
|
||
_defaultHandPose(wristX, wristY, isRight) {
|
||
const s = isRight ? 1 : -1;
|
||
const u = (this.poseH || 512) * 0.018;
|
||
const pts = [
|
||
[0, 0 ], // 0 wrist
|
||
[s*1.5, -1.0 ], // 1 thumb CMC
|
||
[s*2.8, -2.2 ], // 2 thumb MCP
|
||
[s*3.8, -3.2 ], // 3 thumb IP
|
||
[s*4.5, -4.2 ], // 4 thumb TIP
|
||
[s*0.8, -3.5 ], // 5 index MCP
|
||
[s*0.8, -5.2 ], // 6 index PIP
|
||
[s*0.8, -6.5 ], // 7 index DIP
|
||
[s*0.8, -7.5 ], // 8 index TIP
|
||
[s*0.0, -3.8 ], // 9 middle MCP
|
||
[s*0.0, -5.8 ], // 10 middle PIP
|
||
[s*0.0, -7.2 ], // 11 middle DIP
|
||
[s*0.0, -8.2 ], // 12 middle TIP
|
||
[s*-0.8, -3.5 ], // 13 ring MCP
|
||
[s*-0.8, -5.2 ], // 14 ring PIP
|
||
[s*-0.8, -6.5 ], // 15 ring DIP
|
||
[s*-0.8, -7.5 ], // 16 ring TIP
|
||
[s*-1.6, -3.0 ], // 17 pinky MCP
|
||
[s*-1.6, -4.5 ], // 18 pinky PIP
|
||
[s*-1.6, -5.5 ], // 19 pinky DIP
|
||
[s*-1.6, -6.2 ], // 20 pinky TIP
|
||
];
|
||
return pts.map(([dx, dy]) => [wristX + dx * u, wristY + dy * u, 1]);
|
||
}
|
||
|
||
/** Creates a blank scene with default T-pose skeleton on every frame. */
|
||
_newScene(w, h, frameCount, fps, withFace = false) {
|
||
this.poseW = w;
|
||
this.poseH = h;
|
||
this.frameCount = frameCount;
|
||
this.overrides = {};
|
||
this.tweens = {};
|
||
this._undoStack = [];
|
||
this._redoStack = [];
|
||
|
||
const body = this._defaultBodyPose(w, h);
|
||
const rhand = this._defaultHandPose(body[4][0], body[4][1], true);
|
||
const lhand = this._defaultHandPose(body[7][0], body[7][1], false);
|
||
const face = withFace ? this._defaultFacePose(body[0][0], body[0][1]) : null;
|
||
this.frames = {};
|
||
for (let fi = 0; fi < frameCount; fi++) {
|
||
this.frames[fi] = {
|
||
width: w,
|
||
height: h,
|
||
body: body.map(p => [...p]),
|
||
rhand: rhand.map(p => [...p]),
|
||
lhand: lhand.map(p => [...p]),
|
||
face: face ? face.map(p => [...p]) : null,
|
||
};
|
||
}
|
||
if (withFace) this.expandedGroups.add("face");
|
||
|
||
if (this.scrubber) {
|
||
this.scrubber.max = String(Math.max(0, frameCount - 1));
|
||
this.scrubber.value = "0";
|
||
}
|
||
this.frameRangeStart = 0;
|
||
this.frameRangeEnd = frameCount - 1;
|
||
if (this.rangeStartInp) this.rangeStartInp.value = 0;
|
||
if (this.rangeEndInp) this.rangeEndInp.value = frameCount - 1;
|
||
if (this.playFPS !== fps && this._fpsInput) this._fpsInput.value = fps;
|
||
this.playFPS = fps;
|
||
this.graphViewport = { frameStart: 0, frameEnd: Math.max(1, frameCount - 1), valMin: 0, valMax: Math.max(w, h) };
|
||
|
||
this._seekFrame(0);
|
||
this._refreshTimeline();
|
||
}
|
||
|
||
/**
|
||
* Shows the New Scene / Edit Project dialog.
|
||
* mode = "new" | "edit"
|
||
*/
|
||
_showProjectDialog(mode) {
|
||
const isNew = mode === "new";
|
||
const overlay = document.createElement("div");
|
||
Object.assign(overlay.style, {
|
||
position: "fixed", inset: "0", background: "rgba(0,0,0,0.65)",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
zIndex: "100000",
|
||
});
|
||
|
||
const box = document.createElement("div");
|
||
Object.assign(box.style, {
|
||
background: "#0e1020", border: "1px solid #2c3352",
|
||
borderRadius: "10px", padding: "28px 32px", minWidth: "320px",
|
||
color: "#b0bcd4", fontFamily: "'Inter',sans-serif",
|
||
boxShadow: "0 12px 48px rgba(0,0,0,0.9)",
|
||
});
|
||
|
||
const title = document.createElement("div");
|
||
title.textContent = isNew ? "New Scene" : "Edit Project";
|
||
Object.assign(title.style, { fontSize: "16px", fontWeight: "700", marginBottom: "20px", color: "#d4ddf4" });
|
||
box.appendChild(title);
|
||
|
||
const mkRow = (label, inputEl) => {
|
||
const row = document.createElement("div");
|
||
row.style.cssText = "display:flex;align-items:center;gap:10px;margin-bottom:13px;";
|
||
const lbl = document.createElement("label");
|
||
lbl.textContent = label;
|
||
Object.assign(lbl.style, { fontSize: "12px", color: "#99a", width: "110px", flexShrink: "0" });
|
||
Object.assign(inputEl.style, {
|
||
background: "#1e1e30", border: "1px solid #446", borderRadius: "4px",
|
||
color: "#eef", padding: "4px 8px", fontSize: "13px", width: "100px",
|
||
});
|
||
row.append(lbl, inputEl);
|
||
return row;
|
||
};
|
||
const mkInput = (type, val, min, max) => {
|
||
const inp = document.createElement("input");
|
||
Object.assign(inp, { type, value: String(val), min: String(min), max: String(max) });
|
||
return inp;
|
||
};
|
||
|
||
const wInp = mkInput("number", this.poseW, 64, 4096);
|
||
const hInp = mkInput("number", this.poseH, 64, 4096);
|
||
const fInp = mkInput("number", isNew ? 60 : this.frameCount, 1, 10000);
|
||
const rInp = mkInput("number", this.playFPS || 24, 1, 120);
|
||
|
||
// Resolution presets
|
||
const presetSection = document.createElement("div");
|
||
presetSection.style.cssText = "margin-bottom:14px;";
|
||
const presetLbl = document.createElement("div");
|
||
presetLbl.textContent = "Presets";
|
||
presetLbl.style.cssText = "font-size:11px;color:#667;margin-bottom:6px;";
|
||
presetSection.appendChild(presetLbl);
|
||
|
||
const PRESETS_H = [
|
||
{ label: "4K", w: 3840, h: 2160 },
|
||
{ label: "FHD", w: 1920, h: 1080 },
|
||
{ label: "HD", w: 1280, h: 720 },
|
||
{ label: "480p", w: 854, h: 480 },
|
||
{ label: "360p", w: 640, h: 360 },
|
||
];
|
||
const PRESETS_V = [
|
||
{ label: "4K↕", w: 2160, h: 3840 },
|
||
{ label: "FHD↕", w: 1080, h: 1920 },
|
||
{ label: "HD↕", w: 720, h: 1280 },
|
||
{ label: "480↕", w: 480, h: 854 },
|
||
{ label: "360↕", w: 360, h: 640 },
|
||
];
|
||
const mkPresetGroup = (presets, groupLabel) => {
|
||
const wrap = document.createElement("div");
|
||
wrap.style.cssText = "display:flex;align-items:center;gap:4px;margin-bottom:5px;";
|
||
const gl = document.createElement("span");
|
||
gl.textContent = groupLabel;
|
||
gl.style.cssText = "font-size:9px;color:#445;width:26px;flex-shrink:0;";
|
||
wrap.appendChild(gl);
|
||
for (const p of presets) {
|
||
const btn = document.createElement("button");
|
||
btn.textContent = p.label;
|
||
btn.title = `${p.w} × ${p.h}`;
|
||
Object.assign(btn.style, {
|
||
background: "#1e1e30", border: "1px solid #446", color: "#99b",
|
||
borderRadius: "3px", padding: "2px 6px", fontSize: "10px",
|
||
cursor: "pointer", flex: "1",
|
||
});
|
||
btn.addEventListener("click", () => { wInp.value = p.w; hInp.value = p.h; });
|
||
btn.addEventListener("mouseenter", () => { btn.style.background = "#2a2a4a"; btn.style.color = "#ccf"; });
|
||
btn.addEventListener("mouseleave", () => { btn.style.background = "#1e1e30"; btn.style.color = "#99b"; });
|
||
wrap.appendChild(btn);
|
||
}
|
||
return wrap;
|
||
};
|
||
presetSection.appendChild(mkPresetGroup(PRESETS_H, "↔"));
|
||
presetSection.appendChild(mkPresetGroup(PRESETS_V, "↕"));
|
||
box.appendChild(presetSection);
|
||
|
||
box.appendChild(mkRow("Width (px)", wInp));
|
||
box.appendChild(mkRow("Height (px)", hInp));
|
||
box.appendChild(mkRow("Frames", fInp));
|
||
box.appendChild(mkRow("Frame Rate", rInp));
|
||
|
||
// Face checkbox — only on New Scene; Edit Project leaves face state alone
|
||
const faceInp = document.createElement("input");
|
||
faceInp.type = "checkbox";
|
||
faceInp.checked = false;
|
||
if (isNew) {
|
||
const faceRow = document.createElement("div");
|
||
faceRow.style.cssText = "display:flex;align-items:center;gap:10px;margin-bottom:13px;";
|
||
const faceLbl = document.createElement("label");
|
||
faceLbl.textContent = "Include Face";
|
||
Object.assign(faceLbl.style, { fontSize: "12px", color: "#99a", width: "110px", flexShrink: "0", cursor: "pointer" });
|
||
Object.assign(faceInp.style, { width: "16px", height: "16px", cursor: "pointer", accentColor: "#ffd44c" });
|
||
faceLbl.addEventListener("click", () => { faceInp.checked = !faceInp.checked; });
|
||
const faceHint = document.createElement("span");
|
||
faceHint.textContent = "70 default landmarks";
|
||
faceHint.style.cssText = "font-size:10px;color:#667;";
|
||
faceRow.append(faceLbl, faceInp, faceHint);
|
||
box.appendChild(faceRow);
|
||
}
|
||
|
||
if (!isNew) {
|
||
const note = document.createElement("div");
|
||
note.textContent = "Note: changing frame count adds or trims frames at the end.";
|
||
note.style.cssText = "font-size:10px;color:#667;margin-bottom:14px;";
|
||
box.appendChild(note);
|
||
}
|
||
|
||
const btnRow = document.createElement("div");
|
||
btnRow.style.cssText = "display:flex;justify-content:flex-end;gap:10px;margin-top:8px;";
|
||
|
||
const cancelBtn = document.createElement("button");
|
||
cancelBtn.textContent = "Cancel";
|
||
Object.assign(cancelBtn.style, {
|
||
background:"#2a2a3a", border:"1px solid #444", color:"#aaa",
|
||
borderRadius:"5px", padding:"6px 18px", cursor:"pointer", fontSize:"12px",
|
||
});
|
||
cancelBtn.addEventListener("click", () => overlay.remove());
|
||
|
||
const confirmBtn = document.createElement("button");
|
||
confirmBtn.textContent = isNew ? "Create" : "Apply";
|
||
Object.assign(confirmBtn.style, {
|
||
background:"#1a4a8a", border:"1px solid #559", color:"#eef",
|
||
borderRadius:"5px", padding:"6px 18px", cursor:"pointer",
|
||
fontSize:"12px", fontWeight:"bold",
|
||
});
|
||
confirmBtn.addEventListener("click", () => {
|
||
const w = Math.max(64, Math.min(4096, parseInt(wInp.value) || 512));
|
||
const h = Math.max(64, Math.min(4096, parseInt(hInp.value) || 512));
|
||
const fc = Math.max(1, Math.min(10000, parseInt(fInp.value) || 60));
|
||
const fps= Math.max(1, Math.min(120, parseInt(rInp.value) || 24));
|
||
overlay.remove();
|
||
if (isNew) {
|
||
this._newScene(w, h, fc, fps, faceInp.checked);
|
||
} else {
|
||
this._editProject(w, h, fc, fps);
|
||
}
|
||
});
|
||
|
||
btnRow.append(cancelBtn, confirmBtn);
|
||
box.appendChild(btnRow);
|
||
overlay.appendChild(box);
|
||
overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
|
||
document.body.appendChild(overlay);
|
||
fInp.focus(); fInp.select();
|
||
}
|
||
|
||
/** Applies project setting changes without destroying existing keyframes. */
|
||
_editProject(w, h, frameCount, fps) {
|
||
const prevCount = this.frameCount;
|
||
this.poseW = w;
|
||
this.poseH = h;
|
||
this.playFPS = fps;
|
||
if (this._fpsInput) this._fpsInput.value = fps;
|
||
|
||
if (frameCount > prevCount) {
|
||
const body = this._defaultBodyPose(w, h);
|
||
const rhand = this._defaultHandPose(body[4][0], body[4][1], true);
|
||
const lhand = this._defaultHandPose(body[7][0], body[7][1], false);
|
||
for (let fi = prevCount; fi < frameCount; fi++) {
|
||
if (!this.frames[fi]) {
|
||
this.frames[fi] = {
|
||
width: w, height: h,
|
||
body: body.map(p => [...p]),
|
||
rhand: rhand.map(p => [...p]),
|
||
lhand: lhand.map(p => [...p]),
|
||
face: null,
|
||
};
|
||
}
|
||
}
|
||
} else if (frameCount < prevCount) {
|
||
for (let fi = frameCount; fi < prevCount; fi++) {
|
||
delete this.frames[fi];
|
||
delete this.overrides[fi];
|
||
delete this.tweens[fi];
|
||
}
|
||
// Clamp selected keyframes that now fall out of range
|
||
for (const key of [...this.selKfs]) {
|
||
if (parseInt(key.split("::")[0]) >= frameCount) this.selKfs.delete(key);
|
||
}
|
||
}
|
||
|
||
this.frameCount = frameCount;
|
||
this.frameRangeEnd = Math.min(this.frameRangeEnd, frameCount - 1);
|
||
if (this.scrubber) this.scrubber.max = String(Math.max(0, frameCount - 1));
|
||
if (this.rangeEndInp) this.rangeEndInp.value = this.frameRangeEnd;
|
||
|
||
const fi = Math.min(this.currentFrame, frameCount - 1);
|
||
this._seekFrame(fi);
|
||
this._refreshTimeline();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Save / Load project
|
||
// -----------------------------------------------------------------------
|
||
_saveProject() {
|
||
const state = {
|
||
version: 2,
|
||
frame_count: this.frameCount,
|
||
width: this.poseW,
|
||
height: this.poseH,
|
||
frames: this.frames,
|
||
overrides: this.overrides,
|
||
tweens: this.tweens,
|
||
smooth_window: this.smoothWindow,
|
||
interpolation: this.interpolationMode,
|
||
catmull_tension: this.catmullTension,
|
||
reference: this._refMeta || null,
|
||
z_global_offset: this.zGlobalOffset,
|
||
};
|
||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||
a.download = `temporal_editor_${this.nodeId}_${ts}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
_loadProject(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = async (ev) => {
|
||
let data;
|
||
try { data = JSON.parse(ev.target.result); }
|
||
catch { alert("Invalid JSON file"); return; }
|
||
this._pushUndo();
|
||
if (data.overrides) {
|
||
this.overrides = {};
|
||
for (const [k, v] of Object.entries(data.overrides))
|
||
this.overrides[parseInt(k)] = v;
|
||
}
|
||
if (data.z_depth) {
|
||
// Migrate legacy zDepth → overrides[fi][label][3]
|
||
for (const [k, v] of Object.entries(data.z_depth)) {
|
||
const fi = parseInt(k);
|
||
for (const [label, z] of Object.entries(v)) {
|
||
if (!this.overrides[fi]) this.overrides[fi] = {};
|
||
const ov = this.overrides[fi][label];
|
||
if (Array.isArray(ov)) { if (ov[3] === undefined) ov[3] = z; }
|
||
else this.overrides[fi][label] = [0, 0, 1, z];
|
||
}
|
||
}
|
||
}
|
||
if (data.tweens) {
|
||
this.tweens = {};
|
||
for (const [k, v] of Object.entries(data.tweens))
|
||
this.tweens[parseInt(k)] = v;
|
||
}
|
||
if (data.frame_count) {
|
||
this.frameCount = data.frame_count;
|
||
this.scrubber.max = String(Math.max(0, this.frameCount - 1));
|
||
this.frameRangeStart = 0;
|
||
this.frameRangeEnd = this.frameCount - 1;
|
||
if (this.rangeStartInp) this.rangeStartInp.value = 0;
|
||
if (this.rangeEndInp) this.rangeEndInp.value = this.frameRangeEnd;
|
||
this.graphViewport = { frameStart: 0, frameEnd: Math.max(1, this.frameCount - 1), valMin: 0, valMax: Math.max(this.poseW, this.poseH) };
|
||
}
|
||
if (data.width) this.poseW = data.width;
|
||
if (data.height) this.poseH = data.height;
|
||
if (data.smooth_window !== undefined) this.smoothWindow = data.smooth_window;
|
||
if (data.interpolation) this.interpolationMode = data.interpolation;
|
||
if (data.catmull_tension !== undefined) this.catmullTension = data.catmull_tension;
|
||
if (data.frames) {
|
||
this.frames = {};
|
||
for (const [k, v] of Object.entries(data.frames))
|
||
this.frames[parseInt(k)] = v;
|
||
}
|
||
if (data.z_global_offset) this.zGlobalOffset = data.z_global_offset;
|
||
if (data.reference) {
|
||
this._refMeta = data.reference;
|
||
if (this._refOffsetInp) this._refOffsetInp.value = String(data.reference.frameOffset || 0);
|
||
this.referenceImg = null;
|
||
if (this._refVideo) { this._refVideo.src = ""; this._refVideo = null; }
|
||
this._refSeqFrames = null;
|
||
this._showRelinkBanner();
|
||
}
|
||
await this._applyChanges();
|
||
this._refreshTimeline();
|
||
this._seekFrame(0);
|
||
if (this.activeTab === "graph") this._renderGraphEditor();
|
||
this._updateInterpBtns();
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ComfyUI extension
|
||
// ---------------------------------------------------------------------------
|
||
app.registerExtension({
|
||
name: "MAGOS.DWPoseTemporalEditor",
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData.name !== "DWPoseTEEditor") return;
|
||
const orig = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
orig?.call(this);
|
||
for (const w of this.widgets || []) {
|
||
if (w.name === "editor_state_json") { w.computeSize = () => [0,-4]; w.hidden = true; }
|
||
}
|
||
this.addWidget("button","Open Temporal Editor",null,()=>{ new TemporalEditorOverlay(this.id); });
|
||
};
|
||
},
|
||
});
|