import { COCO_KEYPOINTS, getFormat, getFormatForPose, isFormatEditAllowed } from "../formats/index.js"; import { t } from "./i18n.js"; import { UiIcons } from "../ui-icons.js"; import { buildDonationFooterHtml, applyDonationFooterStyles, cloneKeypoints, DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT, DEFAULT_POSE_COCO18, MISSING_KEYPOINT_DRAG_TYPE, MISSING_HAND_DRAG_TYPE, drawBoneWithOutline, drawKeypointWithOutline, scaleKeypointsToCanvas, isValidKeypoint, normalizePresetData, readFileToText, getPersistedSetting, setPersistedSetting, removePersistedSetting, showToast, showConfirm, showPrompt } from "../utils.js"; // Re-export COCO_KEYPOINTS for backwards compatibility export { COCO_KEYPOINTS }; /** * Inject CSS for monochrome SVG UI icons (id-guarded to prevent duplicates). */ function ensureOpenPoseInjectedStyles() { const id = 'openpose-injected-ui-icons-v1'; if (document.getElementById(id)) return; const style = document.createElement('style'); style.id = id; style.textContent = ` /* Monochrome SVG icons (currentColor) */ .openpose-ui-icon { display: block; opacity: 0.72; pointer-events: none; /* icon shouldn't steal clicks */ } button:hover .openpose-ui-icon, .openpose-icon-button:hover .openpose-ui-icon, .openpose-preset-iconbtn:hover .openpose-ui-icon { opacity: 0.92; } /* Compact icon buttons (Preset header) */ .openpose-preset-iconbtn { min-width: 26px; height: 22px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; } /* If the preset header has a row container, keep spacing tight */ .openpose-preset-iconrow { display: inline-flex; align-items: center; gap: 6px; } /* Optional: right-aligned header icon for group titles */ .openpose-group-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .openpose-group-header .openpose-group-icon { opacity: 0.72; } /* Right sidebar module icons - grid-based layout */ .ope-openpose-modal .openpose-coco-keypoint-item.openpose-sidebar-item { display: grid; grid-template-columns: 18px 1fr; column-gap: 10px; align-items: center; padding: 6px 8px; } .openpose-sidebar-icon { width: 14px; height: 14px; display: grid; place-items: center; opacity: 0.78; color: rgba(255, 255, 255, 0.70); } .openpose-sidebar-icon svg { display: block; } .openpose-sidebar-item:hover .openpose-sidebar-icon { opacity: 0.92; color: rgba(255, 255, 255, 0.85); } .openpose-sidebar-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .openpose-sidebar-title { line-height: 1.2; } .openpose-sidebar-desc { line-height: 1.25; } `; document.head.appendChild(style); } const isPoseEditorDebugEnabled = () => { if (typeof globalThis === "undefined") { return false; } return !!globalThis.OpenPoseEditorDebug?.poseEditor; }; // Debug logging helper for OpenPose Studio export function debugLog(...args) { if (!isPoseEditorDebugEnabled()) { return; } console.log(...args); } export function logLayout(label, data) { if (!isPoseEditorDebugEnabled()) { return; } console.debug(`[OpenPose Studio] ${label}`, data); } const POSE_BG_DATA_URL_KEY = "openpose_editor.poseEditor.bg.dataUrl"; const POSE_BG_MODE_KEY = "openpose_editor.poseEditor.bg.mode"; const POSE_BG_OPACITY_KEY = "openpose_editor.poseEditor.bg.opacity"; const POSE_BG_MODES = new Set(["contain", "cover"]); const DEFAULT_BG_MODE = "contain"; const DEFAULT_BG_OPACITY = 0.5; const HAND_KEYPOINT_ROWS = [ { key: "wrist_anchor", rgb: [100, 100, 100] }, { key: "thumb_cmc", rgb: [100, 0, 0] }, { key: "thumb_mcp", rgb: [150, 0, 0] }, { key: "thumb_ip", rgb: [200, 0, 0] }, { key: "thumb_tip", rgb: [255, 0, 0] }, { key: "index_mcp", rgb: [100, 100, 0] }, { key: "index_pip", rgb: [150, 150, 0] }, { key: "index_dip", rgb: [200, 200, 0] }, { key: "index_tip", rgb: [255, 255, 0] }, { key: "middle_mcp", rgb: [0, 100, 50] }, { key: "middle_pip", rgb: [0, 150, 75] }, { key: "middle_dip", rgb: [0, 200, 100] }, { key: "middle_tip", rgb: [0, 255, 125] }, { key: "ring_mcp", rgb: [0, 50, 100] }, { key: "ring_pip", rgb: [0, 75, 150] }, { key: "ring_dip", rgb: [0, 100, 200] }, { key: "ring_tip", rgb: [0, 125, 255] }, { key: "little_mcp", rgb: [100, 0, 100] }, { key: "little_pip", rgb: [150, 0, 150] }, { key: "little_dip", rgb: [200, 0, 200] }, { key: "little_tip", rgb: [255, 0, 255] } ]; function createKeypointRemoveControl() { const control = document.createElement("button"); control.type = "button"; control.className = "openpose-coco-remove-control"; control.textContent = "\u00D7"; control.style.border = "none"; control.style.background = "transparent"; control.style.padding = "0"; control.style.margin = "0"; control.style.width = "28px"; control.style.minWidth = "28px"; control.style.height = "28px"; control.style.minHeight = "28px"; control.style.display = "inline-flex"; control.style.alignItems = "center"; control.style.justifyContent = "center"; control.style.lineHeight = "1"; control.style.fontSize = "14px"; control.style.fontFamily = "Arial, sans-serif"; control.style.color = "var(--openpose-text)"; control.style.opacity = "0.7"; control.style.cursor = "pointer"; control.style.userSelect = "none"; control.style.flexShrink = "0"; control.addEventListener("mouseenter", () => { if (control.dataset.removeDisabled === "1") { return; } control.style.opacity = "1"; control.style.color = "var(--openpose-text)"; }); control.addEventListener("mouseleave", () => { if (control.dataset.removeDisabled === "1") { return; } control.style.opacity = control.dataset.removeBaseOpacity || "0.7"; control.style.color = "var(--openpose-text)"; }); return control; } function setKeypointStatusIconState(statusIcon, state) { statusIcon.textContent = ""; statusIcon.classList.remove("is-present", "is-missing", "is-disabled"); if (state) { statusIcon.classList.add(`is-${state}`); } statusIcon.style.opacity = state === "present" ? "1" : "0.7"; } function createKeypointStatusIcon(state) { const statusIcon = document.createElement("span"); statusIcon.className = "openpose-coco-status-icon openpose-kp-status"; statusIcon.style.flexShrink = "0"; statusIcon.style.width = "16px"; statusIcon.style.minWidth = "16px"; statusIcon.style.flex = "0 0 16px"; statusIcon.style.height = "20px"; statusIcon.style.minHeight = "20px"; statusIcon.style.display = "flex"; statusIcon.style.alignItems = "center"; statusIcon.style.justifyContent = "center"; statusIcon.style.fontSize = "14px"; statusIcon.style.fontWeight = "bold"; statusIcon.style.color = "var(--openpose-text)"; statusIcon.style.padding = "0"; statusIcon.style.margin = "0"; statusIcon.style.lineHeight = "20px"; statusIcon.style.alignSelf = "center"; setKeypointStatusIconState(statusIcon, state); return statusIcon; } function normalizeBackgroundMode(value) { if (POSE_BG_MODES.has(value)) { return value; } return DEFAULT_BG_MODE; } function normalizeBackgroundOpacity(value) { const num = Number(value); if (!Number.isFinite(num)) { return DEFAULT_BG_OPACITY; } return Math.min(1, Math.max(0, num)); } function clampKeypointsToCanvas(points, canvasWidth, canvasHeight) { if (!Array.isArray(points)) { return { keypoints: points, adjustedCount: 0 }; } let adjustedCount = 0; const keypoints = points.map((point) => { if (!isValidKeypoint(point)) { return point; } const x = Number(point[0]); const y = Number(point[1]); let adjustedX = Math.min(canvasWidth, Math.max(0, x)); let adjustedY = Math.min(canvasHeight, Math.max(0, y)); if (adjustedX === x && adjustedY === y) { return point; } if (adjustedX === 0 && adjustedY === 0) { adjustedX = Math.min(1, canvasWidth); adjustedY = Math.min(1, canvasHeight); } adjustedCount += 1; return [adjustedX, adjustedY]; }); return { keypoints, adjustedCount }; } function clampKeypointGroupsToCanvas(groups, canvasWidth, canvasHeight) { if (!Array.isArray(groups)) { return { groups, adjustedCount: 0 }; } let adjustedCount = 0; const adjustedGroups = groups.map((group) => { const adjusted = clampKeypointsToCanvas(group, canvasWidth, canvasHeight); adjustedCount += adjusted.adjustedCount; return adjusted.keypoints; }); return { groups: adjustedGroups, adjustedCount }; } // Helper function to detect COCO keypoint schema and return appropriate title export function getKeypointSchemaTitle(renderer) { if (!renderer) return "COCO Keypoints"; const poses = renderer.getPoses ? renderer.getPoses() : []; // Return generic title only when there are no poses at all if (!poses || poses.length === 0) { return "COCO Keypoints"; } // Use selected pose if available, otherwise fall back to first pose const selectedIndex = renderer.getSelectedPoseIndex ? renderer.getSelectedPoseIndex() : null; const activeIndex = selectedIndex != null && poses[selectedIndex] ? selectedIndex : 0; const keypoints = poses[activeIndex].keypoints; if (!keypoints || keypoints.length < 2) { return "COCO Keypoints"; } // Use stored per-pose formatId; fall back to neck-presence heuristic for old data const pose = poses[activeIndex]; const formatId = pose.formatId || ((keypoints[1] == null) ? "coco17" : "coco18"); const format = getFormat(formatId); return format ? `${format.displayName} Keypoints` : "COCO Keypoints"; } /** * Check if any keypoint from any pose lies outside the canvas bounds. * Includes body keypoints, face keypoints, and hand keypoints if present. * @param {Array} poses - Array of pose objects with keypoints, faceKeypoints, handLeftKeypoints, handRightKeypoints * @param {number} canvasWidth - Canvas width * @param {number} canvasHeight - Canvas height * @returns {{outOfBounds: boolean, count: number}} - Returns object with boolean and count of out-of-bounds keypoints */ export function isAnyKeypointOutOfBounds(poses, canvasWidth, canvasHeight) { if (!Array.isArray(poses) || poses.length === 0) { return { outOfBounds: false, count: 0 }; } const extractCoords = (point) => { if (!point) { return null; } if (Array.isArray(point)) { if (point.length < 2) { return null; } return { x: Number(point[0]), y: Number(point[1]) }; } if (typeof point === "object") { return { x: Number(point.x), y: Number(point.y) }; } return null; }; const countOutOfBounds = (points) => { if (!Array.isArray(points)) { return 0; } let count = 0; for (const point of points) { const coords = extractCoords(point); if (!coords) { continue; } const x = coords.x; const y = coords.y; if (!Number.isFinite(x) || !Number.isFinite(y)) { continue; } if (x < 0 || y < 0 || x > canvasWidth || y > canvasHeight) { count++; } } return count; }; let outOfBoundsCount = 0; for (const pose of poses) { if (!pose) { continue; } outOfBoundsCount += countOutOfBounds(pose.keypoints || pose); outOfBoundsCount += countOutOfBounds(pose.faceKeypoints || pose.face_keypoints_2d); outOfBoundsCount += countOutOfBounds(pose.handLeftKeypoints || pose.hand_left_keypoints_2d); outOfBoundsCount += countOutOfBounds(pose.handRightKeypoints || pose.hand_right_keypoints_2d); } return { outOfBounds: outOfBoundsCount > 0, count: outOfBoundsCount }; } export const poseEditorCanvasWorkflow = { setupMissingInsertHandle(handle, item, insert, label) { if (!handle || !item || !insert) { return; } handle.classList.add("openpose-keypoint-insert-handle"); handle.title = label; handle.setAttribute("aria-label", label); handle.setAttribute("role", "button"); handle.setAttribute("draggable", "true"); handle.addEventListener("pointerdown", (event) => { if (event.pointerType !== "mouse") { this.beginPointerInsertDrag(event, handle, item, insert, label); } }); handle.addEventListener("dragstart", (event) => { if (!event.dataTransfer) { event.preventDefault(); return; } item.classList.add("is-pointer-dragging"); if (insert.type === "hand") { event.dataTransfer.setData(MISSING_HAND_DRAG_TYPE, insert.side); this._draggingMissingHand = true; this.renderer?.setHandInsertPreviewSide?.(insert.side); } else { event.dataTransfer.setData(MISSING_KEYPOINT_DRAG_TYPE, `${insert.keypointId}`); this._draggingMissingKeypoint = true; } event.dataTransfer.setData("text/plain", label); event.dataTransfer.effectAllowed = "copy"; }); handle.addEventListener("dragend", () => { item.classList.remove("is-pointer-dragging"); this._draggingMissingKeypoint = false; this._draggingMissingHand = false; this.renderer?.setHandInsertPreviewSide?.(null); }); }, beginPointerInsertDrag(event, handle, item, insert, label) { if (event.isPrimary === false || event.button !== 0) { return; } this._cancelPointerInsertDrag?.("replaced"); event.preventDefault(); event.stopPropagation(); const pointerId = event.pointerId; const startX = event.clientX; const startY = event.clientY; let dragging = false; let overCanvas = false; let dragFeedback = null; const updateDragFeedback = (pointerEvent) => { if (!dragFeedback) { dragFeedback = document.createElement("div"); dragFeedback.className = "openpose-pointer-drag-feedback"; const swatch = item.querySelector(".openpose-coco-color-swatch")?.cloneNode(true); if (swatch) { dragFeedback.appendChild(swatch); } const text = document.createElement("span"); text.textContent = label || handle.getAttribute("aria-label") || "Keypoint"; dragFeedback.appendChild(text); const add = document.createElement("span"); add.className = "openpose-pointer-drag-feedback-add"; add.textContent = "+"; dragFeedback.appendChild(add); document.body.appendChild(dragFeedback); } dragFeedback.classList.toggle("is-over-canvas", overCanvas); dragFeedback.style.transform = `translate3d(${pointerEvent.clientX + 14}px, ${pointerEvent.clientY - 52}px, 0)`; }; const isOverCanvas = (pointerEvent) => { const rect = this.canvasElem?.getBoundingClientRect(); return !!rect && rect.width > 0 && rect.height > 0 && pointerEvent.clientX >= rect.left && pointerEvent.clientX <= rect.right && pointerEvent.clientY >= rect.top && pointerEvent.clientY <= rect.bottom; }; const updatePointer = (pointerEvent) => { if (pointerEvent.pointerId !== pointerId) { return; } if (!dragging && Math.hypot(pointerEvent.clientX - startX, pointerEvent.clientY - startY) >= 6) { dragging = true; item.classList.add("is-pointer-dragging"); if (insert.type === "hand") { this._draggingMissingHand = true; this.renderer?.setHandInsertPreviewSide?.(insert.side); } else { this._draggingMissingKeypoint = true; } updateDragFeedback(pointerEvent); } if (!dragging) { return; } pointerEvent.preventDefault(); overCanvas = isOverCanvas(pointerEvent); this._dragOverCanvas = overCanvas; this._setCanvasDropHighlight?.(overCanvas, pointerEvent, "pointer-drag"); updateDragFeedback(pointerEvent); if (insert.type === "hand") { this.renderer?.setHandInsertPreviewPointer?.( overCanvas ? this.renderer.screenToLogical(pointerEvent.clientX, pointerEvent.clientY) : null ); } }; const cleanup = (reason = "pointer-drag-end") => { window.removeEventListener("pointermove", updatePointer, true); window.removeEventListener("pointerup", finishPointer, true); window.removeEventListener("pointercancel", cancelPointer, true); item.classList.remove("is-pointer-dragging"); this._draggingMissingKeypoint = false; this._draggingMissingHand = false; this._dragOverCanvas = false; this.renderer?.setHandInsertPreviewSide?.(null); this.renderer?.setHandInsertPreviewPointer?.(null); dragFeedback?.remove(); dragFeedback = null; this._pointerInsertDrag = null; this._cancelPointerInsertDrag = null; this._forceClearCanvasDropHighlight?.(reason); }; const finishPointer = (pointerEvent) => { if (pointerEvent.pointerId !== pointerId) { return; } pointerEvent.preventDefault(); pointerEvent.stopPropagation(); overCanvas = dragging && isOverCanvas(pointerEvent); cleanup(overCanvas ? "pointer-drop" : "pointer-drag-end"); if (overCanvas) { if (insert.type === "hand") { void this.placeMissingHandAt(insert.side, pointerEvent.clientX, pointerEvent.clientY); } else { this.placeMissingKeypointAt(insert.keypointId, pointerEvent.clientX, pointerEvent.clientY); } } else if (!dragging) { this.beginCanvasInsert?.(insert); } }; const cancelPointer = (pointerEvent) => { if (pointerEvent.pointerId === pointerId) { cleanup("pointer-drag-cancel"); } }; this._pointerInsertDrag = { pointerId, handle, insert }; this._cancelPointerInsertDrag = cleanup; window.addEventListener("pointermove", updatePointer, true); window.addEventListener("pointerup", finishPointer, true); window.addEventListener("pointercancel", cancelPointer, true); }, setupEditorControls(container) { this.fileInput = container.querySelector(".openpose-file-input"); this.fileInput.addEventListener("change", this.onLoad.bind(this)); // Button event handlers container.querySelector('[data-action="add"]').addEventListener("click", () => { const presetId = this.presetSelect ? this.presetSelect.value : null; this.addPresetToCanvas(presetId); }); container.querySelector('[data-action="remove"]').addEventListener("click", async () => { const selectedIndex = this.renderer.getSelectedPoseIndex(); if (selectedIndex < 0) { return; } const confirmed = await showConfirm( t("pose_editor.confirm.remove_pose.title"), t("pose_editor.confirm.remove_pose.body", { number: selectedIndex + 1 }), ); if (!confirmed) { return; } this.removePose(); this.recordHistory(); this.saveToNode(); this.refreshCocoKeypointsPanel(); }); container.querySelector('[data-action="reset"]').addEventListener("click", async () => { const confirmed = await showConfirm( t("pose_editor.confirm.clear_all.title"), t("pose_editor.confirm.clear_all.body"), ); if (!confirmed) { return; } this.resizeCanvas(DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT); this.resetCanvas(); // Clear undo/redo history to make the operation non-reversible this.undo_history = []; this.redo_history = []; this.updateUndoButton(); this.recordHistory(); this.saveToNode(); this.refreshCocoKeypointsPanel(); // Show success toast after clearing showToast("success", "Pose Editor", t("toast.canvas_cleared")); }); container.querySelector('[data-action="reset-size"]').addEventListener("click", () => { this.resizeCanvas(DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT); this.recordHistory(); this.saveToNode(); }); container.querySelector('[data-action="save"]').addEventListener("click", () => this.save()); container.querySelector('[data-action="load"]').addEventListener("click", () => this.load()); this.undoButtons = Array.from(container.querySelectorAll('[data-action="undo"]')); this.undoButtons.forEach((button) => { button.addEventListener("click", () => this.undo()); }); container.querySelector('[data-action="ok"]').addEventListener("click", () => this.confirmAndClose()); container.querySelector('[data-action="preset-prev"]').addEventListener("click", () => { this.stepPreset(-1); }); container.querySelector('[data-action="preset-next"]').addEventListener("click", () => { this.stepPreset(1); }); this.bindPresetReloadButtons = (root = container) => { root.querySelectorAll('[data-action="presets-reload"]').forEach((btn) => { if (btn.dataset.presetsReloadReady) { return; } btn.dataset.presetsReloadReady = "1"; btn.addEventListener("click", () => { this.loadPresetsFromJson(); }); }); }; this.bindPresetReloadButtons(container); // Background controls this.bgFileInput = container.querySelector(".openpose-bg-file-input"); this.bgModeSelect = container.querySelector(".openpose-bg-mode-select"); this.opacitySlider = container.querySelector(".openpose-opacity-slider"); this.opacityValue = container.querySelector(".openpose-opacity-value"); this.backgroundImage = null; this.backgroundOpacity = DEFAULT_BG_OPACITY; this.backgroundMode = DEFAULT_BG_MODE; this.setBackgroundControlsEnabled(false); this.restoreSessionBackground(); container.querySelector('[data-action="load-bg"]').addEventListener("click", () => { this.bgFileInput.value = null; this.bgFileInput.click(); }); container.querySelector('[data-action="clear-bg"]').addEventListener("click", () => { this.clearBackground(); }); const toggleAreasBtn = container.querySelector('[data-action="toggle-areas"]'); if (toggleAreasBtn) { this.toggleAreasButton = toggleAreasBtn; toggleAreasBtn.addEventListener("click", async () => { const hasAreas = this.renderer.conditioningAreas && this.renderer.conditioningAreas.length > 0; if (!hasAreas) { // No areas source connected — show install prompt instead of toggling const confirmed = await showConfirm( t("pose_editor.areas.no_source.title"), t("pose_editor.areas.no_source.body"), ); if (confirmed) { window.open("https://github.com/andreszs/ComfyUI-Lora-Pipeline", "_blank", "noopener,noreferrer"); } return; } // Normal toggle: areas are available const nowVisible = !this.renderer.conditioningAreasVisible; this.renderer.setConditioningAreasVisible(nowVisible); toggleAreasBtn.style.opacity = nowVisible ? "1" : "0.45"; toggleAreasBtn.title = nowVisible ? t("pose_editor.areas.toggle.hide_title") : t("pose_editor.areas.toggle.show_title"); }); } this.bgFileInput.addEventListener("change", (e) => { this.onLoadBackground(e); }); this.bgModeSelect.addEventListener("change", () => { this.backgroundMode = this.bgModeSelect.value; this.updateSessionBackground({ mode: this.backgroundMode }); this.applyBackground(); }); this.opacitySlider.addEventListener("input", () => { this.backgroundOpacity = this.opacitySlider.value / 100; if (this.opacityValue) { this.opacityValue.textContent = `${this.opacitySlider.value}%`; } this.updateSessionBackground({ opacity: this.backgroundOpacity }); this.applyBackground(); }); // Width/Height inputs this.widthInput = container.querySelector(".openpose-width-input"); this.heightInput = container.querySelector(".openpose-height-input"); this.canvasBoundsWarningBanner = container.querySelector(".openpose-canvas-bounds-warning-banner"); if (this.widthInput) { this.widthInput.addEventListener("change", () => { if (this.heightInput) { this.resizeCanvas(+this.widthInput.value, +this.heightInput.value); this.recordHistory(); this.saveToNode(); } }); } if (this.heightInput) { this.heightInput.addEventListener("change", () => { if (this.widthInput) { this.resizeCanvas(+this.widthInput.value, +this.heightInput.value); this.recordHistory(); this.saveToNode(); } }); } // Apply styling to canvas bounds warning banner if (this.canvasBoundsWarningBanner) { this.applyCanvasBoundsWarningStyles(this.canvasBoundsWarningBanner); } }, tryExpandPanelForCanvas(targetCanvasWidth, availableCanvasWidth) { if (this.isMaximized || !this.panel || !this.canvasArea) { return false; } const available = Number(availableCanvasWidth) || 0; const needed = Math.ceil(targetCanvasWidth - available); if (needed <= 0) { return false; } const panelRect = this.panel.getBoundingClientRect(); const currentWidth = panelRect.width || this.panel.offsetWidth || 0; if (currentWidth <= 0) { return false; } const maxUsableWidth = this.getMaxUsablePanelWidth(); if (maxUsableWidth <= currentWidth) { return false; } const targetWidth = Math.min(maxUsableWidth, Math.ceil(currentWidth + needed)); if (targetWidth <= currentWidth) { return false; } const previousRestore = Math.max(0, Number(this.panelRestoreWidth) || 0); const nextRestore = Math.max(previousRestore, targetWidth); if (nextRestore <= previousRestore) { return false; } this.panelRestoreWidth = nextRestore; this.applyPanelLayout(); return true; }, scheduleCanvasFit(attempt = 0) { if (!this.renderer) { return; } const maxAttempts = 6; requestAnimationFrame(() => { requestAnimationFrame(() => { const area = this.canvasStage || this.canvasArea; if (area && (area.clientWidth <= 0 || area.clientHeight <= 0)) { if (attempt < maxAttempts) { setTimeout(() => this.scheduleCanvasFit(attempt + 1), 0); } return; } this.resizeCanvas(this.canvasWidth, this.canvasHeight); this.renderer.requestRedraw(); }); }); }, calcResolution(width, height) { let availableWidth = 0; let availableHeight = 0; const measureArea = this.canvasStage || this.canvasArea; if (measureArea) { const styles = window.getComputedStyle(measureArea); const padX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight); const padY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom); availableWidth = measureArea.clientWidth - padX; availableHeight = measureArea.clientHeight - padY; logLayout(measureArea === this.canvasStage ? "calcResolution:canvasStage" : "calcResolution:canvasArea", { clientWidth: measureArea.clientWidth, clientHeight: measureArea.clientHeight, paddingLeft: styles.paddingLeft, paddingRight: styles.paddingRight, paddingTop: styles.paddingTop, paddingBottom: styles.paddingBottom, availableWidth, availableHeight }); } if (availableWidth <= 0 || availableHeight <= 0) { // Fallback based on panel sizing heuristics availableWidth = Math.min(1100, window.innerWidth * 0.8) - 240; availableHeight = Math.min(750, window.innerHeight * 0.8) - 80; logLayout("calcResolution:fallback", { windowWidth: window.innerWidth, windowHeight: window.innerHeight, availableWidth, availableHeight }); } const ratio = Math.min(availableWidth / width, availableHeight / height, 1); logLayout("calcResolution:result", { inputWidth: width, inputHeight: height, ratio, outputWidth: width * ratio, outputHeight: height * ratio }); return { width: width * ratio, height: height * ratio, availableWidth, availableHeight }; }, resizeCanvas(width, height) { const handEditActive = !!this.renderer?.isHandEditModeActive?.(); const viewportSize = Math.max(width, height); const resolution = handEditActive ? this.calcResolution(viewportSize, viewportSize) : this.calcResolution(width, height); const cssWidth = Math.round(resolution["width"]); const cssHeight = Math.round(resolution["height"]); this.canvasWidth = width; this.canvasHeight = height; if (this.widthInput) { this.widthInput.value = `${width}`; } if (this.heightInput) { this.heightInput.value = `${height}`; } // Use renderer to set canvas size this.renderer.setSize(width, height, cssWidth, cssHeight); this.canvasElem.style.width = `${cssWidth}px`; this.canvasElem.style.height = `${cssHeight}px`; // Re-apply background image if present if (this.backgroundImage) { this.applyBackground(); } // Update canvas bounds warning based on new canvas size this.updateCanvasBoundsWarning(); }, addPresetToCanvas(presetId, options = {}) { const { silent = false } = options; // Check if this is an invalid file preset if (presetId && presetId.startsWith("invalid:")) { const filename = presetId.slice("invalid:".length); const emptyFile = this.emptyPoseFiles?.find(f => f.filename === filename); if (emptyFile) { showToast("error", t("toast.cannot_load_pose_title"), `${filename}\n${emptyFile.reason}`); } return; } const preset = this.presets.find(item => item.id === presetId) || this.presets[0]; const rawKeypoints = preset ? preset.keypoints : cloneKeypoints(DEFAULT_POSE_COCO18.keypoints); // Allow caller to provide detected format id (from drag payload). Prefer explicit // formatId from options to avoid relying on sidebar selection or defaults. let detectedFormatPre = null; if (options && options.formatId) { detectedFormatPre = getFormat(options.formatId) || getFormatForPose(rawKeypoints); } else { detectedFormatPre = getFormatForPose(rawKeypoints); } const kpCountPre = detectedFormatPre && detectedFormatPre.keypoints ? detectedFormatPre.keypoints.length : 18; if (rawKeypoints.length >= kpCountPre) { const firstPersonKeypoints = rawKeypoints.slice(0, kpCountPre); if (!this.checkFormatCompatibility(firstPersonKeypoints)) { return; } } if (detectedFormatPre && !isFormatEditAllowed(detectedFormatPre.id)) { showToast("warn", "Pose Editor", t(`toast.${detectedFormatPre.id}_edit_disabled`), 7000); return; } const presetResolution = this.getPresetResolution(presetId); if (presetResolution) { // Auto-expand canvas if needed (never shrink) const oldWidth = this.canvasWidth; const oldHeight = this.canvasHeight; const newWidth = Math.max(oldWidth, presetResolution.width); const newHeight = Math.max(oldHeight, presetResolution.height); if (newWidth > oldWidth || newHeight > oldHeight) { this.resizeCanvas(newWidth, newHeight); showToast( "info", "OpenPose Studio", t("toast.canvas_auto_resized", { oldWidth, oldHeight, newWidth, newHeight }), 4500 ); } } const bodyAdjustment = clampKeypointsToCanvas( this.getPresetKeypoints(presetId), this.canvasWidth, this.canvasHeight ); const faceAdjustment = clampKeypointGroupsToCanvas( this.getPresetFaceKeypoints(presetId), this.canvasWidth, this.canvasHeight ); const presetHands = this.getPresetHandKeypoints(presetId); const leftHandAdjustment = clampKeypointGroupsToCanvas( presetHands.left, this.canvasWidth, this.canvasHeight ); const rightHandAdjustment = clampKeypointGroupsToCanvas( presetHands.right, this.canvasWidth, this.canvasHeight ); const keypoints = bodyAdjustment.keypoints; const faceKeypoints = faceAdjustment.groups; const handLeftKeypoints = leftHandAdjustment.groups; const handRightKeypoints = rightHandAdjustment.groups; const adjustedKeypointCount = bodyAdjustment.adjustedCount + faceAdjustment.adjustedCount + leftHandAdjustment.adjustedCount + rightHandAdjustment.adjustedCount; // Detect format (or reuse provided formatId) and get keypoint count for multi-person handling let detectedFormat = null; if (options && options.formatId) { detectedFormat = getFormat(options.formatId) || getFormatForPose(keypoints); } else { detectedFormat = getFormatForPose(keypoints); } const kpCount = detectedFormat && detectedFormat.keypoints ? detectedFormat.keypoints.length : 18; // Check format compatibility before adding any poses (avoids partial multi-person additions) if (keypoints.length >= kpCount) { const firstPersonKeypoints = keypoints.slice(0, kpCount); if (!this.checkFormatCompatibility(firstPersonKeypoints)) { return; } } // Handle multi-person presets by chunking every kpCount keypoints let personIndex = 0; for (let i = 0; i < keypoints.length; i += kpCount) { const personKeypoints = keypoints.slice(i, i + kpCount); const faceGroup = Array.isArray(faceKeypoints) ? faceKeypoints[personIndex] : null; const handLeftGroup = Array.isArray(handLeftKeypoints) ? handLeftKeypoints[personIndex] : null; const handRightGroup = Array.isArray(handRightKeypoints) ? handRightKeypoints[personIndex] : null; this.addPose(personKeypoints, faceGroup, handLeftGroup, handRightGroup, detectedFormat?.id || null); this.renderer.setSelectedPose(null); personIndex += 1; } // Trigger final redraw after all poses added this.renderer.requestRedraw(); debugLog('[OpenPose Studio] Preset added. Total poses:', this.renderer.getPoses().length); this.recordHistory(); this.saveToNode(); // Refresh COCO keypoints panel to show the new pose format this.refreshCocoKeypointsPanel(); // Show success feedback only if not silent if (!silent) { const fmtName = detectedFormat && detectedFormat.displayName ? detectedFormat.displayName : "Pose"; showToast("success", "Pose Editor", t("toast.pose_added", { formatName: fmtName })); } if (!silent && adjustedKeypointCount > 0) { const messageKey = adjustedKeypointCount === 1 ? "toast.keypoint_auto_adjusted" : "toast.keypoints_auto_adjusted"; showToast( "warn", "OpenPose Studio", t(messageKey, { count: adjustedKeypointCount }), 6000 ); } }, addPose(keypoints = undefined, faceKeypoints = null, handLeftKeypoints = null, handRightKeypoints = null, formatId = null) { if (keypoints === undefined) { keypoints = cloneKeypoints(DEFAULT_POSE_COCO18.keypoints); } this.renderer.addPoseFromArray(keypoints, faceKeypoints, handLeftKeypoints, handRightKeypoints, formatId); this.updateRemoveState(); // Trigger redraw to make new pose visible this.renderer.requestRedraw(); debugLog('[OpenPose Studio] Pose added. Total poses:', this.renderer.getPoses().length); return true; }, setPose(keypoints) { // Clear all poses and load from flat array via renderer this.renderer.loadFromFlatArray(keypoints); // Trigger redraw after loading all poses this.renderer.requestRedraw(); debugLog('[OpenPose Studio] setPose completed. Total poses:', this.renderer.getPoses().length); this.saveToNode(); this.refreshCocoKeypointsPanel(); // After setPose, set baseline history and enable undo const state = this.renderer.serialize(); const selection = this.renderer.getSelectedPoseIndex(); const snapshot = JSON.stringify({ state, selection }); this.undo_history = [snapshot]; this.redo_history = []; this.updateUndoButton(); this._initializing = false; this.updateCanvasBoundsWarning(); }, removePose() { // Use renderer API to remove selected pose const selectedIndex = this.renderer.getSelectedPoseIndex(); if (selectedIndex < 0) { return; } this.renderer.removePose(selectedIndex); this.updateRemoveState(); }, resetCanvas() { // Clear all poses via renderer this.renderer.load({ width: this.canvasWidth, height: this.canvasHeight, keypoints: [] }); // Re-apply background image if present if (this.backgroundImage) { this.applyBackground(); } this.updateRemoveState(); this._initializing = false; this.updateCanvasBoundsWarning(); }, handleEditorKeyDown(e) { if (this.renderer?.isHandEditModeActive?.() && ["ArrowDown", "ArrowUp", "Delete", "Backspace"].includes(e.key)) { e.preventDefault(); e.stopImmediatePropagation(); return; } if ((e.key === "ArrowDown" || e.key === "ArrowUp")) { const direction = e.key === "ArrowDown" ? 1 : -1; this.stepPreset(direction); e.preventDefault(); e.stopImmediatePropagation(); return; } if (e.key === "Delete") { const selectedIndex = this.renderer?.getSelectedPoseIndex(); if (selectedIndex !== null && selectedIndex >= 0) { const selectedHand = this.renderer?.getSelectedHand?.(); const selectedKeypointIds = this.renderer?.selectedKeypointIds; if (selectedHand?.poseIndex === selectedIndex) { const isLeft = selectedHand.side === "left"; const didClear = isLeft ? this.renderer.clearHandLeftKeypoints(selectedIndex) : this.renderer.clearHandRightKeypoints(selectedIndex); if (didClear) { showToast("success", "Pose Editor", t(isLeft ? "toast.left_hand_removed" : "toast.right_hand_removed")); } } else if (selectedKeypointIds && selectedKeypointIds.size > 0) { // Delete key with active keypoint selection: remove only the selected keypoints for (const kpId of selectedKeypointIds) { this.renderer.clearKeypoint(selectedIndex, kpId); } this.renderer.selectedKeypointIds = new Set(); this.recordHistory(); this.saveToNode(); this.refreshCocoKeypointsPanel(); } else { // Delete key with no keypoint selection: remove the selected pose this.removePose(); this.recordHistory(); this.saveToNode(); this.refreshCocoKeypointsPanel(); } e.preventDefault(); e.stopImmediatePropagation(); } } if (e.key === "Backspace") { // Backspace key: undo last action if (this.undo_history.length > 1) { this.undo(); e.preventDefault(); e.stopImmediatePropagation(); } } } }; export const poseEditorSubsystemWorkflow = { refreshCocoKeypointsPanel() { // Update header title with detected keypoint schema if (this.cocoKeypointsLabel) { this.cocoKeypointsLabel.textContent = getKeypointSchemaTitle(this.renderer); } this.updateExtraButtonsState(); // Update keypoints list this.refreshCocoKeypointsList(); }, updateExtraButtonsState() { const removeFaceBtn = this.removeFaceButton; const removeHandsBtn = this.removeHandsButton; const rightActions = this.container ? this.container.querySelector(".openpose-right-actions") : null; if (!removeFaceBtn && !removeHandsBtn) { if (rightActions) { rightActions.style.display = "none"; } return; } const poses = this.renderer ? this.renderer.getPoses() : []; const hasPose = poses.length > 0; const selectedIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null; const activeIndex = selectedIndex != null && selectedIndex >= 0 ? selectedIndex : (hasPose ? 0 : null); const activePose = activeIndex != null ? poses[activeIndex] : null; const canEdit = selectedIndex != null && selectedIndex >= 0; const getBlockStatus = (keypoints) => { const hasBlock = Array.isArray(keypoints); const hasData = hasBlock && keypoints.some((kp) => kp); return { hasBlock, hasData }; }; const faceStatus = activePose ? getBlockStatus(activePose.faceKeypoints) : { hasBlock: false, hasData: false }; const leftStatus = activePose ? getBlockStatus(activePose.handLeftKeypoints) : { hasBlock: false, hasData: false }; const rightStatus = activePose ? getBlockStatus(activePose.handRightKeypoints) : { hasBlock: false, hasData: false }; const showFace = hasPose && faceStatus.hasBlock; const showHands = hasPose && (leftStatus.hasBlock || rightStatus.hasBlock); const enableFace = canEdit && faceStatus.hasData; const enableHands = canEdit && (leftStatus.hasData || rightStatus.hasData); const updateButton = (button, visible, enabled) => { if (!button) { return; } button.style.display = visible ? "" : "none"; button.disabled = !enabled; button.style.opacity = enabled ? "1" : "0.5"; button.style.cursor = enabled ? "pointer" : "not-allowed"; button.style.background = enabled ? "var(--openpose-btn-bg)" : "var(--openpose-btn-disabled-bg)"; }; updateButton(removeFaceBtn, showFace, enableFace); updateButton(removeHandsBtn, showHands, enableHands); if (rightActions) { rightActions.style.display = (showFace || showHands) ? "flex" : "none"; } }, refreshHandKeypointsList(mode) { if (!this.cocoKeypointsList || !mode) { return; } if (this.cocoKeypointsLabel) { this.cocoKeypointsLabel.textContent = t(`pose_editor.keypoints.${mode.side === "right" ? "right" : "left"}_hand_title`); this.cocoKeypointsLabel.style.display = "block"; this.cocoKeypointsLabel.classList.remove("openpose-coco-empty-section-label"); this.cocoKeypointsLabel.style.marginBottom = "0"; } this.cocoKeypointsList.innerHTML = ""; this.cocoKeypointsList.style.display = "flex"; this.cocoKeypointsList.style.flexDirection = "column"; this.cocoKeypointsList.style.gap = "2px"; this.cocoKeypointsList.style.flex = "1 1 auto"; this.cocoKeypointsList.style.overflowY = "auto"; for (let keypointId = 0; keypointId < HAND_KEYPOINT_ROWS.length; keypointId++) { const definition = HAND_KEYPOINT_ROWS[keypointId]; const keypointLabel = t(`pose_editor.keypoints.hand.${definition.key}`); const isPresent = !!mode.keypoints?.[keypointId]; const isEditable = keypointId !== 0; const item = document.createElement("div"); item.className = "openpose-coco-keypoint-item openpose-hand-keypoint-item"; item.dataset.handKeypointId = `${keypointId}`; item.title = keypointLabel; if (!isPresent) { item.classList.add("openpose-hand-keypoint-missing"); } const leftContent = document.createElement("div"); leftContent.className = "openpose-hand-keypoint-content"; const swatch = document.createElement("span"); swatch.className = "openpose-coco-color-swatch"; swatch.style.backgroundColor = `rgb(${definition.rgb.join(", ")})`; const name = document.createElement("span"); name.className = "openpose-coco-keypoint-name"; name.textContent = keypointLabel; leftContent.appendChild(swatch); leftContent.appendChild(name); const statusIcon = createKeypointStatusIcon(isPresent ? "present" : (isEditable ? "missing" : "disabled")); const rightContent = document.createElement("div"); rightContent.className = "openpose-hand-keypoint-controls openpose-keypoint-row-controls"; if (!isPresent || !isEditable) { rightContent.appendChild(statusIcon); } if (isPresent && isEditable) { const removeControl = createKeypointRemoveControl(); removeControl.dataset.removeDisabled = "0"; removeControl.dataset.removeBaseOpacity = "0.7"; removeControl.title = t("pose_editor.keypoints.remove_title", { target: keypointLabel }); removeControl.addEventListener("click", (event) => { event.stopPropagation(); this.renderer?.clearHandEditKeypoint?.(keypointId); }); rightContent.appendChild(removeControl); } item.appendChild(leftContent); item.appendChild(rightContent); this.cocoKeypointRowElements.set(keypointId, { item, name, statusIcon }); if (isPresent && isEditable) { item.addEventListener("mouseenter", () => { this.renderer?.setHoveredHandEditKeypointId?.(keypointId); }); item.addEventListener("mouseleave", () => { this.renderer?.setHoveredHandEditKeypointId?.(null); }); } else if (!isPresent && isEditable) { this.setupMissingInsertHandle( statusIcon, item, { type: "keypoint", keypointId }, keypointLabel, ); } this.cocoKeypointsList.appendChild(item); } this.refreshCocoKeypointRowStyles(); }, refreshCocoKeypointsList() { if (!this.cocoKeypointsList) { return; } // DEBUG: Check the browser console for detailed logs when hovering "Left Ear" on canvas // Expected to see: // 1. Canvas hover detection: "[canvas2d] Detected hover on keypoint ID: 17" // 2. Canvas hover state change logs // 3. Sidebar styling check logs showing if keypointId 17 is in cocoKeypointRowElements // 4. Details about selectedKeypoints array length and format this.hoveredKeypointId = null; this.cocoKeypointRowElements.clear(); // Read from renderer (single source of truth) const poses = this.renderer ? this.renderer.getPoses() : []; const selectedIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null; const noPoses = poses.length === 0; // Style + wire donation footer buttons (shared module) const donationRoot = this.cocoKeypointsList ? this.cocoKeypointsList.closest(".openpose-coco-keypoints-card") : null; if (donationRoot) { applyDonationFooterStyles(donationRoot, () => this.setActiveTab("about")); } const handEditMode = this.renderer?.getHandEditModeInfo?.(); if (handEditMode) { this.refreshHandKeypointsList(handEditMode); return; } // Handle empty state if (noPoses) { const applyEmptySectionLabelStyles = (label) => { if (!label) { return; } label.style.color = "var(--openpose-text-muted)"; label.style.fontFamily = "Arial, sans-serif"; label.style.fontSize = "12px"; label.style.fontWeight = "normal"; label.style.marginTop = "6px"; label.style.marginBottom = "10px"; label.style.textAlign = "left"; }; const keypointsCard = this.cocoKeypointsList ? this.cocoKeypointsList.closest(".openpose-coco-keypoints-card") : null; if (keypointsCard) { let headline = keypointsCard.querySelector(".openpose-coco-empty-headline"); if (!headline) { headline = document.createElement("div"); headline.className = "openpose-coco-empty-headline"; const insertTarget = this.cocoKeypointsLabel?.closest(".openpose-coco-keypoints-header") || this.cocoKeypointsList; keypointsCard.insertBefore(headline, insertTarget); } headline.textContent = `${t("pose_editor.empty.headline")} \u{1F938}`; headline.style.color = "var(--openpose-text)"; headline.style.fontFamily = "Arial, sans-serif"; headline.style.fontSize = "13px"; headline.style.fontWeight = "600"; headline.style.padding = "4px 6px"; headline.style.margin = "0 0 10px 0"; headline.style.textAlign = "left"; headline.style.display = "block"; } if (this.cocoKeypointsLabel) { this.cocoKeypointsLabel.textContent = t("pose_editor.help.getting_started_label"); this.cocoKeypointsLabel.style.display = "block"; this.cocoKeypointsLabel.classList.add("openpose-coco-empty-section-label"); applyEmptySectionLabelStyles(this.cocoKeypointsLabel); } const moduleManager = this.moduleManager; const emptyActions = moduleManager?.getEmptyActions?.() || []; const emptyActionsHtml = emptyActions .map((action) => { const icon = action.icon || ""; const text = action.text || ""; if (!text) { return ""; } return `
`; }) .join(""); const tabSummaries = moduleManager?.getTabSummaries?.() || []; const tabRowsHtml = tabSummaries .map((summary) => { const icon = summary.icon || ""; const title = summary.title || ""; const description = summary.description || ""; if (!title) { return ""; } return ` `; }) .join(""); const tabsSectionHtml = tabRowsHtml ? ` ${tabRowsHtml}` : ""; this.cocoKeypointsList.innerHTML = ` `; this.cocoKeypointsList.style.display = "flex"; this.cocoKeypointsList.style.flexDirection = "column"; this.cocoKeypointsList.style.gap = "0"; this.cocoKeypointsList.style.flex = "1 1 auto"; this.cocoKeypointsList.style.minHeight = "0"; this.cocoKeypointsList.style.overflowY = "hidden"; this.cocoKeypointsList.style.alignItems = "stretch"; const emptyList = this.cocoKeypointsList.querySelector(".openpose-coco-empty-list"); if (emptyList) { emptyList.style.display = "flex"; emptyList.style.flexDirection = "column"; emptyList.style.gap = "2px"; emptyList.style.flex = "1 1 auto"; emptyList.style.minHeight = "0"; emptyList.style.overflowY = "auto"; emptyList.style.paddingTop = "2px"; } const sectionSpacer = this.cocoKeypointsList.querySelector(".openpose-coco-empty-section-spacer"); if (sectionSpacer) { sectionSpacer.style.height = "0"; sectionSpacer.style.flexShrink = "0"; sectionSpacer.style.borderTop = "1px solid var(--openpose-border)"; sectionSpacer.style.marginTop = "8px"; sectionSpacer.style.marginBottom = "8px"; sectionSpacer.style.width = "100%"; } this.cocoKeypointsList.querySelectorAll(".openpose-coco-empty-section-label").forEach((label) => { applyEmptySectionLabelStyles(label); // Reduce contrast for empty state section headings label.style.opacity = "0.65"; }); this.cocoKeypointsList.querySelectorAll(".openpose-coco-keypoint-item").forEach((item) => { item.style.borderRadius = "4px"; item.style.boxSizing = "border-box"; item.style.backgroundColor = "var(--openpose-input-bg)"; item.style.fontSize = "12px"; item.style.fontFamily = "Arial, sans-serif"; item.style.color = "var(--openpose-text-muted)"; // Slightly reduce contrast for empty state list items item.style.opacity = "0.8"; item.style.minHeight = "20px"; }); this.cocoKeypointsList.querySelectorAll(".openpose-sidebar-title").forEach((title) => { title.style.fontWeight = "600"; title.style.color = "var(--openpose-input-text)"; }); this.cocoKeypointsList.querySelectorAll(".openpose-sidebar-desc").forEach((desc) => { desc.style.fontSize = "11px"; desc.style.color = "var(--openpose-text-muted)"; // Make descriptions very subdued (almost background text) desc.style.opacity = "0.45"; }); return; } // Normal state: show list and title this.cocoKeypointsList.style.display = "flex"; const keypointsCard = this.cocoKeypointsList ? this.cocoKeypointsList.closest(".openpose-coco-keypoints-card") : null; if (keypointsCard) { const headline = keypointsCard.querySelector(".openpose-coco-empty-headline"); if (headline) { headline.style.display = "none"; } } if (this.cocoKeypointsLabel) { this.cocoKeypointsLabel.textContent = getKeypointSchemaTitle(this.renderer); this.cocoKeypointsLabel.style.display = "block"; this.cocoKeypointsLabel.classList.remove("openpose-coco-empty-section-label"); this.cocoKeypointsLabel.style.marginBottom = "0"; } // Extract selected pose keypoints let selectedKeypoints = null; if (selectedIndex != null && poses[selectedIndex]) { selectedKeypoints = poses[selectedIndex].keypoints; } const canEdit = selectedIndex != null && selectedIndex >= 0; const activePose = canEdit ? poses[selectedIndex] : (poses.length > 0 ? poses[0] : null); const activeKeypoints = activePose ? activePose.keypoints : null; debugLog('[refreshCocoKeypointsList] Debug:', { selectedIndex, selectedKeypoints: selectedKeypoints ? selectedKeypoints.map(kp => kp ? 'present' : 'null') : 'none' }); // Sidebar is disabled ONLY when there are no poses at all const isDisabled = noPoses; // For displaying keypoint presence, use selected pose or fallback to first pose const displayKeypoints = selectedKeypoints || (poses.length > 0 ? poses[0].keypoints : []); // Use stored per-pose formatId; fall back to keypoint-based detection for old data let activeFormat = null; if (activePose) { activeFormat = getFormat(activePose.formatId) || getFormatForPose(activePose.keypoints); } else if (poses.length > 0) { const fallbackPose = poses[0]; activeFormat = getFormat(fallbackPose.formatId) || getFormatForPose(fallbackPose.keypoints); } else { activeFormat = getFormatForPose([]); } this.cocoKeypointsList.innerHTML = ""; // Apply list container styles this.cocoKeypointsList.style.display = "flex"; this.cocoKeypointsList.style.flexDirection = "column"; this.cocoKeypointsList.style.gap = "2px"; this.cocoKeypointsList.style.flex = "1 1 auto"; this.cocoKeypointsList.style.overflowY = "auto"; // Create list items from active format keypoints (skip non-present/null entries) const formatKeypoints = Array.isArray(activeFormat?.keypoints) ? activeFormat.keypoints : COCO_KEYPOINTS; const presentIndexSet = activeFormat?.presentIndices instanceof Set ? activeFormat.presentIndices : new Set(Array.from({ length: formatKeypoints.length }, (_, i) => i)); const skeletonEdges = Array.isArray(activeFormat?.skeletonEdges) ? activeFormat.skeletonEdges : []; const keypointCount = formatKeypoints.length; const adjacency = Array.from({ length: keypointCount }, () => []); for (const edge of skeletonEdges) { if (!Array.isArray(edge) || edge.length < 2) { continue; } const [a, b] = edge; if (!Number.isInteger(a) || !Number.isInteger(b)) { continue; } if (a < 0 || b < 0 || a >= keypointCount || b >= keypointCount) { continue; } if (!presentIndexSet.has(a) || !presentIndexSet.has(b)) { continue; } adjacency[a].push(b); adjacency[b].push(a); } const getRemovalRoots = (formatId) => { if (formatId === "coco18") { return [1]; } if (formatId === "coco17") { return [0, 2, 5, 8, 11]; } return []; }; const depth = new Array(keypointCount).fill(null); const queue = []; const seedRoot = (rootId) => { if (rootId == null || rootId < 0 || rootId >= keypointCount) { return; } if (!presentIndexSet.has(rootId)) { return; } if (depth[rootId] != null) { return; } depth[rootId] = 0; queue.push(rootId); }; const runBfs = () => { while (queue.length > 0) { const current = queue.shift(); const nextDepth = depth[current] + 1; for (const neighbor of adjacency[current]) { if (depth[neighbor] == null) { depth[neighbor] = nextDepth; queue.push(neighbor); } } } }; getRemovalRoots(activeFormat?.id).forEach(seedRoot); runBfs(); for (let i = 0; i < keypointCount; i++) { if (!presentIndexSet.has(i)) { continue; } if (depth[i] == null) { seedRoot(i); runBfs(); } } const isKeypointRemovable = (keypointId, keypoints) => { return !!(keypoints && keypoints[keypointId]); }; const createRemoveControl = createKeypointRemoveControl; // Debug: log format info debugLog('[refreshCocoKeypointsList] Format:', activeFormat?.id, 'Keypoint count:', formatKeypoints.length, 'presentIndices:', Array.from(presentIndexSet)); for (let i = 0; i < formatKeypoints.length; i++) { if (!presentIndexSet.has(i)) { debugLog('[refreshCocoKeypointsList] Skipping index', i, '(not in presentIndexSet)'); continue; } const keypoint = formatKeypoints[i]; if (!keypoint) { debugLog('[refreshCocoKeypointsList] Skipping index', i, '(keypoint is null)'); continue; } const keypointLabel = activeFormat?.id === "coco18" ? t(`pose_editor.keypoints.coco18.${keypoint.name.toLowerCase().replace(/ /g, "_")}`) : (keypoint.name || `Keypoint ${i}`); // Determine presence const keypointId = i; const isPresent = !isDisabled && displayKeypoints[keypointId] != null; const isMissing = canEdit && !isDisabled && !isPresent; // Debug: log when adding rows for indices 16 and 17 if (i === 16 || i === 17) { debugLog('[refreshCocoKeypointsList] Creating row for index', i, 'name:', keypoint.name); } const item = document.createElement("div"); item.className = "openpose-coco-keypoint-item"; item.style.display = "flex"; item.style.alignItems = "center"; item.style.justifyContent = "space-between"; item.style.gap = "6px"; item.style.padding = "3px 6px"; item.style.borderRadius = "4px"; item.style.boxSizing = "border-box"; item.style.backgroundColor = "var(--openpose-input-bg)"; item.style.fontSize = "12px"; item.style.fontFamily = "Arial, sans-serif"; item.style.color = "var(--openpose-input-text)"; item.style.minHeight = "20px"; if (isMissing) { item.classList.add("openpose-keypoint-missing"); } // Left container: color swatch + keypoint name const leftContent = document.createElement("div"); leftContent.style.display = "flex"; leftContent.style.alignItems = "center"; leftContent.style.gap = "6px"; leftContent.style.minWidth = "0"; leftContent.style.flex = "1"; // Color swatch const swatch = document.createElement("span"); swatch.className = "openpose-coco-color-swatch"; swatch.style.width = "16px"; swatch.style.height = "16px"; swatch.style.borderRadius = "3px"; swatch.style.flexShrink = "0"; swatch.style.border = "1px solid var(--openpose-border)"; swatch.style.backgroundColor = `rgb(${keypoint.rgb.join(", ")})`; // Keypoint name const name = document.createElement("span"); name.className = "openpose-coco-keypoint-name"; name.textContent = keypointLabel; name.style.flex = "1"; name.style.whiteSpace = "nowrap"; name.style.overflow = "hidden"; name.style.textOverflow = "ellipsis"; name.style.transition = "color 0.15s ease"; leftContent.appendChild(swatch); leftContent.appendChild(name); // Right container: status indicator const statusIcon = createKeypointStatusIcon( isDisabled ? "disabled" : (isPresent ? "present" : (isMissing ? "missing" : "disabled")) ); const rightContent = document.createElement("div"); rightContent.className = "openpose-keypoint-row-controls"; rightContent.style.display = "flex"; rightContent.style.alignItems = "center"; rightContent.style.gap = "4px"; rightContent.style.flexShrink = "0"; rightContent.style.width = "36px"; rightContent.style.minWidth = "36px"; rightContent.style.justifyContent = "center"; if (!isPresent || !canEdit) { rightContent.appendChild(statusIcon); } if (isPresent && canEdit) { const removeControl = createRemoveControl(); removeControl.dataset.removeDisabled = "0"; removeControl.dataset.removeBaseOpacity = "0.7"; removeControl.style.opacity = "0.7"; removeControl.style.cursor = "pointer"; removeControl.title = t("pose_editor.keypoints.remove_title", { target: keypointLabel }); removeControl.addEventListener("click", (event) => { event.stopPropagation(); const activeIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null; if (activeIndex == null || activeIndex < 0) { return; } const posesNow = this.renderer ? this.renderer.getPoses() : []; const pose = posesNow[activeIndex]; if (!pose || !pose.keypoints || !pose.keypoints[keypointId]) { return; } const didClear = this.renderer.clearKeypoint(activeIndex, keypointId); if (didClear) { showToast("success", "Pose Editor", `${keypointLabel} ` + t("toast.removed")); } }); rightContent.appendChild(removeControl); } item.appendChild(leftContent); item.appendChild(rightContent); // Store row elements for style updates this.cocoKeypointRowElements.set(keypointId, { item, name, statusIcon }); if (keypointId === 16 || keypointId === 17) { debugLog('[refreshCocoKeypointsList] Stored row for keypointId:', keypointId); } // Set up interactions only for present keypoints if (!isDisabled && isPresent) { item.style.cursor = "pointer"; item.addEventListener("mouseenter", () => { this.hoveredKeypointId = keypointId; // Highlight keypoint in renderer if (this.renderer) { this.renderer.setHoveredKeypointId(keypointId); } this.refreshCocoKeypointRowStyles(); }); item.addEventListener("mouseleave", () => { this.hoveredKeypointId = null; // Clear keypoint highlight in renderer if (this.renderer) { this.renderer.setHoveredKeypointId(null); } this.refreshCocoKeypointRowStyles(); }); } else if (isMissing && canEdit) { this.setupMissingInsertHandle( statusIcon, item, { type: "keypoint", keypointId }, keypointLabel, ); } else { item.style.cursor = "default"; item.style.opacity = "0.6"; } this.cocoKeypointsList.appendChild(item); } const selectedPose = selectedIndex != null && poses[selectedIndex] ? poses[selectedIndex] : null; const selectedExtras = {}; if (selectedPose && Array.isArray(selectedPose.faceKeypoints)) { selectedExtras.face_keypoints_2d = selectedPose.faceKeypoints; } if (selectedPose && Array.isArray(selectedPose.handLeftKeypoints)) { selectedExtras.hand_left_keypoints_2d = selectedPose.handLeftKeypoints; } if (selectedPose && Array.isArray(selectedPose.handRightKeypoints)) { selectedExtras.hand_right_keypoints_2d = selectedPose.handRightKeypoints; } debugLog("[refreshCocoKeypointsList] Selected pose extras keys:", Object.keys(selectedExtras)); const hasFace = Array.isArray(selectedExtras.face_keypoints_2d) && selectedExtras.face_keypoints_2d.length > 0 && selectedExtras.face_keypoints_2d.some(kp => kp); const hasLeftHand = Array.isArray(selectedExtras.hand_left_keypoints_2d) && selectedExtras.hand_left_keypoints_2d.length > 0 && selectedExtras.hand_left_keypoints_2d.some(kp => kp); const hasRightHand = Array.isArray(selectedExtras.hand_right_keypoints_2d) && selectedExtras.hand_right_keypoints_2d.length > 0 && selectedExtras.hand_right_keypoints_2d.some(kp => kp); const addExtraActionRow = ({ label, emoji, side = null, missing = false, inactive = false, onEdit = null, onHover = null, onRemove = null }) => { const item = document.createElement("div"); item.className = "openpose-coco-keypoint-item"; if (missing) { item.classList.add("openpose-keypoint-missing"); } if (inactive) { item.classList.add("openpose-hand-row-inactive"); item.classList.add("openpose-keypoint-disabled"); } item.style.display = "flex"; item.style.alignItems = "center"; item.style.justifyContent = "space-between"; item.style.gap = "6px"; item.style.padding = "3px 6px"; item.style.borderRadius = "4px"; item.style.boxSizing = "border-box"; item.style.backgroundColor = "var(--openpose-input-bg)"; item.style.fontSize = "12px"; item.style.fontFamily = "Arial, sans-serif"; item.style.color = "var(--openpose-input-text)"; item.style.minHeight = "20px"; item.style.cursor = "default"; if (!inactive) { item.addEventListener("mouseenter", () => { item.style.backgroundColor = "var(--openpose-primary-hover-bg)"; onHover?.(true); }); item.addEventListener("mouseleave", () => { item.style.backgroundColor = "var(--openpose-input-bg)"; onHover?.(false); }); } const leftContent = document.createElement("div"); leftContent.style.display = "flex"; leftContent.style.alignItems = "center"; leftContent.style.gap = "6px"; leftContent.style.minWidth = "0"; leftContent.style.flex = "1"; const swatch = document.createElement("span"); swatch.className = "openpose-coco-color-swatch"; swatch.style.width = "16px"; swatch.style.height = "16px"; swatch.style.borderRadius = "3px"; swatch.style.flexShrink = "0"; swatch.style.border = "1px solid var(--openpose-border)"; swatch.style.display = "inline-flex"; swatch.style.alignItems = "center"; swatch.style.justifyContent = "center"; swatch.style.fontSize = "12px"; swatch.style.lineHeight = "1"; swatch.style.backgroundColor = "var(--openpose-input-bg)"; swatch.textContent = emoji; const name = document.createElement("span"); name.className = "openpose-coco-keypoint-name"; name.textContent = label; name.style.flex = "1"; name.style.whiteSpace = "nowrap"; name.style.overflow = "hidden"; name.style.textOverflow = "ellipsis"; name.style.transition = "color 0.15s ease"; leftContent.appendChild(swatch); leftContent.appendChild(name); const rightContent = document.createElement("div"); rightContent.className = "openpose-keypoint-row-controls"; rightContent.style.display = "flex"; rightContent.style.alignItems = "center"; rightContent.style.gap = "4px"; rightContent.style.flexShrink = "0"; rightContent.style.width = onEdit ? "56px" : "36px"; rightContent.style.minWidth = onEdit ? "56px" : "36px"; rightContent.style.height = "20px"; rightContent.style.minHeight = "20px"; rightContent.style.justifyContent = "center"; const insertHandle = missing ? createKeypointStatusIcon("missing") : null; if (insertHandle) { rightContent.appendChild(insertHandle); } if (onEdit) { const editControl = document.createElement("button"); editControl.type = "button"; editControl.className = "openpose-coco-edit-control"; editControl.innerHTML = UiIcons.svg('pencil', { size: 14, className: 'openpose-ui-icon' }); const editIcon = editControl.querySelector(".openpose-ui-icon"); if (editIcon) { editIcon.style.opacity = "1"; } editControl.style.border = "none"; editControl.style.background = "transparent"; editControl.style.padding = "0"; editControl.style.width = "16px"; editControl.style.minWidth = "16px"; editControl.style.height = "16px"; editControl.style.display = "inline-flex"; editControl.style.alignItems = "center"; editControl.style.justifyContent = "center"; editControl.style.color = "var(--openpose-text)"; editControl.style.opacity = "0.7"; editControl.style.cursor = "pointer"; editControl.title = t("pose_editor.keypoints.edit_title", { target: label }); editControl.setAttribute("aria-label", editControl.title); editControl.addEventListener("mouseenter", () => { editControl.style.opacity = "1"; editControl.style.color = "var(--openpose-text)"; }); editControl.addEventListener("mouseleave", () => { editControl.style.opacity = "0.7"; editControl.style.color = "var(--openpose-text)"; }); editControl.addEventListener("click", (event) => { event.stopPropagation(); onEdit(); }); rightContent.appendChild(editControl); } item.appendChild(leftContent); if (onRemove) { const removeControl = createRemoveControl(); removeControl.dataset.removeDisabled = "0"; removeControl.dataset.removeBaseOpacity = "0.7"; removeControl.style.opacity = "0.7"; removeControl.style.cursor = "pointer"; removeControl.title = t("pose_editor.keypoints.remove_title", { target: label }); removeControl.addEventListener("click", (event) => { event.stopPropagation(); onRemove(); }); rightContent.appendChild(removeControl); } item.appendChild(rightContent); if (missing && side) { this.setupMissingInsertHandle( insertHandle, item, { type: "hand", side }, label, ); } this.cocoKeypointsList.appendChild(item); }; if (hasFace) { addExtraActionRow({ label: "Face", emoji: "\u{1F642}", onRemove: () => { const activeIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null; if (activeIndex == null || activeIndex < 0) { return; } const didClear = this.renderer.clearFaceKeypoints(activeIndex); if (didClear) { showToast("success", "Pose Editor", t("toast.face_removed")); this.refreshCocoKeypointsPanel(); } } }); } const addHandActionRow = (side, present, canEditHand) => { const isLeft = side === "left"; const label = t(`pose_editor.keypoints.${side}_hand`); addExtraActionRow({ label, emoji: isLeft ? "\u270B" : "\u{1F91A}", side: canEditHand ? side : null, missing: canEditHand && !present, inactive: !canEditHand, onHover: canEditHand && present ? (hovered) => this.renderer?.setSidebarHoveredHandSide?.(hovered ? side : null) : null, onEdit: canEditHand && present ? () => { const activeIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null; if (activeIndex != null && activeIndex >= 0) { this.renderer.enterHandEditMode(activeIndex, side); } } : null, onRemove: canEditHand && present ? () => { const activeIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null; if (activeIndex == null || activeIndex < 0) { return; } const didClear = isLeft ? this.renderer.clearHandLeftKeypoints(activeIndex) : this.renderer.clearHandRightKeypoints(activeIndex); if (didClear) { showToast("success", "Pose Editor", t(isLeft ? "toast.left_hand_removed" : "toast.right_hand_removed")); this.refreshCocoKeypointsPanel(); } } : null }); }; if (activePose) { addHandActionRow("left", hasLeftHand, !!selectedPose); addHandActionRow("right", hasRightHand, !!selectedPose); } // Apply styles to all rows this.refreshCocoKeypointRowStyles(); }, refreshCocoKeypointRowStyles() { const handEditMode = this.renderer?.getHandEditModeInfo?.(); if (handEditMode) { for (const [keypointId, { item, name, statusIcon }] of this.cocoKeypointRowElements) { const isPresent = !!handEditMode.keypoints?.[keypointId]; const isHovered = keypointId === handEditMode.hoveredKeypointId || keypointId === handEditMode.activeKeypointId; const isEditable = keypointId !== 0; item.style.backgroundColor = isHovered ? "var(--openpose-primary-hover-bg)" : "var(--openpose-input-bg)"; item.style.opacity = "1"; item.style.cursor = isEditable ? (isPresent ? "pointer" : "grab") : "default"; name.style.color = isHovered ? "var(--openpose-text)" : "var(--openpose-input-text)"; item.classList.toggle("openpose-keypoint-disabled", !isEditable && !isPresent); item.classList.toggle("openpose-keypoint-missing", isEditable && !isPresent); item.classList.toggle("openpose-hand-keypoint-missing", isEditable && !isPresent); if (statusIcon) { setKeypointStatusIconState(statusIcon, isPresent ? "present" : (isEditable ? "missing" : "disabled")); } } return; } // Apply styles to all keypoint rows, respecting hover state // Read from renderer (single source of truth) const poses = this.renderer ? this.renderer.getPoses() : []; const selectedIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null; const canvasHoveredKeypointId = this.renderer ? this.renderer.getCanvasHoveredKeypointId() : null; // Debug: log canvas hover state if (canvasHoveredKeypointId !== null) { debugLog('[refreshCocoKeypointRowStyles] Canvas hover ID:', canvasHoveredKeypointId, 'Sidebar row keys:', Array.from(this.cocoKeypointRowElements.keys())); if (selectedIndex !== null && poses[selectedIndex]) { const selectedKps = poses[selectedIndex].keypoints; debugLog('[refreshCocoKeypointRowStyles] Selected pose keypoints length:', selectedKps.length, 'Content:', selectedKps.map((kp, i) => i === 16 || i === 17 ? `[${i}]:${kp ? 'present' : 'null'}` : null).filter(Boolean)); } } let selectedKeypoints = null; if (selectedIndex != null && poses[selectedIndex]) { selectedKeypoints = poses[selectedIndex].keypoints; } const noPoses = poses.length === 0; const canEdit = selectedIndex != null && selectedIndex >= 0; // Sidebar rows are non-editable when there are no poses at all OR when no pose is selected const isDisabled = noPoses || !canEdit; // Display keypoint presence only from the selected pose; never fall back to first pose const displayKeypoints = selectedKeypoints || []; for (const [keypointId, { item, name, statusIcon }] of this.cocoKeypointRowElements) { // Debug: detailed logging for indices 16 and 17 if (keypointId === 16 || keypointId === 17) { debugLog(`[refreshCocoKeypointRowStyles] Processing row keypointId=${keypointId}:`, { isDisabled, selectedKeypointsLength: selectedKeypoints ? selectedKeypoints.length : null, isPresent: selectedKeypoints ? selectedKeypoints[keypointId] != null : false, canvasHoveredKeypointId }); } if (isDisabled) { // Neutral state: uniformly dimmed, no hover highlight name.style.color = "var(--openpose-input-text)"; item.style.backgroundColor = "var(--openpose-input-bg)"; item.style.opacity = ""; item.classList.add("openpose-keypoint-disabled"); if (statusIcon) { statusIcon.style.color = "var(--openpose-text)"; setKeypointStatusIconState(statusIcon, "disabled"); } item.classList.remove("openpose-keypoint-missing"); continue; } const isPresent = displayKeypoints && displayKeypoints[keypointId] != null; const isHovered = keypointId === this.hoveredKeypointId && isPresent; // Canvas hover works even if keypoint is not "present" in sidebar, since we're hovering it directly on canvas const isCanvasHovered = keypointId === canvasHoveredKeypointId; const isMissing = !isPresent; if (statusIcon) { setKeypointStatusIconState(statusIcon, isMissing ? "missing" : "present"); } // Priority: direct sidebar hover > canvas hover > disabled > missing > normal if (isHovered) { name.style.color = "var(--openpose-text)"; item.style.backgroundColor = "var(--openpose-input-bg)"; item.style.opacity = "1"; item.classList.remove("openpose-keypoint-disabled"); if (statusIcon) { statusIcon.style.color = "var(--openpose-text)"; } item.classList.remove("openpose-keypoint-missing"); } else if (isCanvasHovered) { // Canvas hover: white text with prominent primary hover background name.style.color = "var(--openpose-text)"; item.style.backgroundColor = "var(--openpose-primary-hover-bg)"; item.style.opacity = "1"; item.classList.remove("openpose-keypoint-disabled"); if (statusIcon) { statusIcon.style.color = "var(--openpose-text)"; } item.classList.remove("openpose-keypoint-missing"); } else if (isMissing) { // Missing keypoint - subtle solid background + grip name.style.color = "var(--openpose-input-text)"; item.style.backgroundColor = "var(--openpose-input-bg)"; item.style.opacity = "1"; item.classList.remove("openpose-keypoint-disabled"); if (statusIcon) { statusIcon.style.color = "var(--openpose-text)"; } item.classList.add("openpose-keypoint-missing"); } else { // Normal present state name.style.color = "var(--openpose-input-text)"; item.style.backgroundColor = "var(--openpose-input-bg)"; item.style.opacity = "1"; item.classList.remove("openpose-keypoint-disabled"); if (statusIcon) { statusIcon.style.color = "var(--openpose-text)"; } item.classList.remove("openpose-keypoint-missing"); } } }, showKeypointHoverRing(keypointId) { // Hover ring is now handled by OpenPoseCanvas2D renderer if (!this.renderer) { return; } // Renderer displays hover ring visually }, hideKeypointHoverRing() { // Hover ring is now handled by OpenPoseCanvas2D renderer if (!this.renderer) { return; } // TODO: Implement hover ring cleanup for OpenPoseCanvas2D renderer }, setSidebarControlsDisabled(disabled) { const leftSidebar = this.leftSidebar; const rightSidebar = this.rightSidebar; const updateControlState = (control, enabled) => { control.disabled = !enabled; control.style.opacity = enabled ? "1" : "0.5"; if (control.tagName === "BUTTON") { control.style.cursor = enabled ? "pointer" : "not-allowed"; } else { control.style.cursor = enabled ? "" : "not-allowed"; } }; const disableControls = (sidebar, allowedActions = []) => { if (!sidebar) { return; } const controls = Array.from(sidebar.querySelectorAll("button, input, select, textarea")) .filter((control) => ( !control.classList.contains("openpose-support-btn") && !control.closest(".openpose-hand-keypoint-item") )); controls.forEach((control) => { if (control.dataset.sidebarPrevDisabled === undefined) { control.dataset.sidebarPrevDisabled = control.disabled ? "1" : "0"; } updateControlState(control, false); }); allowedActions.forEach((action) => { const button = sidebar.querySelector(`[data-action="${action}"]`); if (!button) { return; } updateControlState(button, true); }); }; const restoreControls = (sidebar) => { if (!sidebar) { return; } const controls = Array.from(sidebar.querySelectorAll("button, input, select, textarea")); controls.forEach((control) => { if (control.dataset.sidebarPrevDisabled !== undefined) { const wasDisabled = control.dataset.sidebarPrevDisabled === "1"; delete control.dataset.sidebarPrevDisabled; updateControlState(control, !wasDisabled); } else { updateControlState(control, true); } }); }; if (disabled) { disableControls(leftSidebar, []); disableControls(rightSidebar, []); } else { restoreControls(leftSidebar); restoreControls(rightSidebar); } }, recordHistory() { if (this.lockMode) { return; } if (this.lockMode || this._initializing) { } // Store renderer state and selection index const state = this.renderer.serialize({ includeExtras: true }); const selection = this.renderer.getSelectedPoseIndex(); const keypointEdits = this.renderer.hasKeypointEdits ? this.renderer.hasKeypointEdits() : false; const snapshot = JSON.stringify({ state, selection, keypointEdits }); const last = this.undo_history[this.undo_history.length - 1]; if (snapshot === last) { return; } this.undo_history.push(snapshot); this.redo_history.length = 0; this.updateUndoButton(); }, onRendererChange(reason) { if (this._initializing) return; // Renderer emits change reasons: 'geometry', 'add', 'delete', 'select', 'clear', 'extras' // Only record history on completed interactions (not during drag/move) if (reason === 'geometry' || reason === 'add' || reason === 'delete' || reason === 'clear' || reason === 'extras') { // These are finalized state changes (drag completed, pose added/removed, canvas cleared) this.recordHistory(); this.saveToNode(); this.refreshCocoKeypointsPanel(); this.updateCanvasBoundsWarning(); } else if (reason === 'select') { // Selection change: update UI but don't record history this.updateRemoveState(); this.refreshCocoKeypointsPanel(); } // All other reasons (if any) are ignored to prevent excessive history recording }, applyHistoryState(snapshot, selectionIndex = null) { if (!snapshot) { return; } this.lockMode = true; const error = this.loadJSON(snapshot); this.lockMode = false; if (error) { console.warn("[OpenPose Studio] Failed to restore history state:", error); return; } this.restoreSelection(selectionIndex); this.scheduleCanvasFit(); this.saveToNode(); this.updateUndoButton(); this.updateCanvasBoundsWarning(); }, updateUndoButton() { if (!this.undoButtons?.length) { return; } const enabled = this.undo_history.length > 1; this.undoButtons.forEach((button) => { button.disabled = !enabled; }); }, undo() { if (this.undo_history.length <= 1) { return; } const current = this.undo_history.pop(); this.redo_history.push(current); const previous = this.undo_history[this.undo_history.length - 1]; // Restore renderer state and selection try { const { state, selection, keypointEdits } = JSON.parse(previous); this.renderer.load(state); this.renderer.setSelectedPose(selection); if (this.renderer.setKeypointEdits) { this.renderer.setKeypointEdits(keypointEdits); } this.saveToNode(); this.refreshCocoKeypointsPanel(); this.updateUndoButton(); this.updateCanvasBoundsWarning(); } catch (e) { console.warn("[OpenPose Studio] Failed to restore undo state:", e); } }, redo() { if (this.redo_history.length === 0) { return; } const snapshot = this.redo_history.pop(); this.undo_history.push(snapshot); // Restore renderer state and selection try { const { state, selection, keypointEdits } = JSON.parse(snapshot); this.renderer.load(state); this.renderer.setSelectedPose(selection); if (this.renderer.setKeypointEdits) { this.renderer.setKeypointEdits(keypointEdits); } this.saveToNode(); this.refreshCocoKeypointsPanel(); this.updateUndoButton(); this.updateCanvasBoundsWarning(); } catch (e) { console.warn("[OpenPose Studio] Failed to restore redo state:", e); } }, saveToNode(force = false) { // During editing (allowCommitToNode=false), skip writing to node // This ensures changes are only persisted on Apply, not during editing if (this._initializing || !this.allowCommitToNode) { return; } const json = this.serializeJSON(); // Use direct property assignment instead of setProperty to avoid // errors when node.options is not initialized if (!this.node.properties) { this.node.properties = {}; } this.node.properties.savedPose = json; // Update the pose_json widget directly if (this.node.jsonWidget) { this.node.jsonWidget.value = json; } // Update node preview if (this.node.updatePreview) { this.node.updatePreview(); } }, load() { this.fileInput.value = null; this.fileInput.click(); }, async onLoad(e) { const file = this.fileInput.files[0]; if (!file) { return; } try { const text = await readFileToText(file); if (typeof text !== "string" || text.trim().length === 0) { showToast("error", "Pose Editor", t("toast.empty_file")); return; } let presetIndex = null; let preflightError = null; try { const parsed = JSON.parse(text); const normalized = normalizePresetData(parsed, file.name); if (normalized && Array.isArray(normalized.presets) && normalized.presets.length > 1) { const poseCount = normalized.presets.length; const promptMessage = `Found ${poseCount} poses in this file. Enter the index of the pose you want to load (0–${poseCount - 1}).`; const selected = await showPrompt("Select pose index", promptMessage, "0"); if (selected === null) { preflightError = t("toast.pose_selection_canceled"); } else { const selectedIndex = Number(String(selected).trim()); if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= normalized.presets.length) { preflightError = `Invalid pose index "${selected}".`; } else { presetIndex = selectedIndex; } } } } catch (error) { // Ignore preflight errors; loadJSON will report parsing issues. } if (preflightError) { showToast("error", t("toast.failed_load_pose_title"), preflightError); return; } const error = await this.loadJSON(text, file.name, { presetIndex }); if (error != null) { showToast("error", t("toast.failed_load_pose_title"), error); } else { this.recordHistory(); this.saveToNode(); // Refresh COCO keypoints panel after loading this.refreshCocoKeypointsPanel(); } } catch (err) { showToast("error", "Pose Editor", t("toast.failed_read_file")); } }, shouldPreserveExtraKeypoints() { if (!this.renderer) return false; const poses = this.renderer.getPoses(); if (!Array.isArray(poses) || poses.length === 0) return false; return poses.some(p => (Array.isArray(p.faceKeypoints) && p.faceKeypoints.length > 0) || (Array.isArray(p.handLeftKeypoints) && p.handLeftKeypoints.length > 0) || (Array.isArray(p.handRightKeypoints) && p.handRightKeypoints.length > 0) ); }, buildOpenPosePayload(includeExtras) { if (!this.renderer) { return { canvas_width: this.canvasWidth, canvas_height: this.canvasHeight, people: [] }; } const state = this.renderer.serialize({ includeExtras: true }); const width = Number(state.width) || this.canvasWidth || DEFAULT_CANVAS_WIDTH; const height = Number(state.height) || this.canvasHeight || DEFAULT_CANVAS_HEIGHT; const poseGroups = Array.isArray(state.keypoints) ? state.keypoints : []; const faceGroups = Array.isArray(state.face_keypoints_2d) ? state.face_keypoints_2d : []; const handLeftGroups = Array.isArray(state.hand_left_keypoints_2d) ? state.hand_left_keypoints_2d : []; const handRightGroups = Array.isArray(state.hand_right_keypoints_2d) ? state.hand_right_keypoints_2d : []; const FACE_KEYPOINT_COUNT = 70; const HAND_KEYPOINT_COUNT = 21; const DEFAULT_SCORE = 1; const hasNonZeroCoords = (group) => { return Array.isArray(group) && group.length > 0; }; const flattenKeypoints = (group, count) => { const total = Number.isFinite(count) && count > 0 ? count : 0; const output = new Array(total * 3).fill(0); if (!Array.isArray(group)) { return output; } const max = Math.min(group.length, total); for (let i = 0; i < max; i++) { const point = group[i]; let x = null; let y = null; if (Array.isArray(point) && point.length >= 2) { x = Number(point[0]); y = Number(point[1]); } else if (point && typeof point === "object") { x = Number(point.x); y = Number(point.y); } const hasCoords = Number.isFinite(x) && Number.isFinite(y); const baseIndex = i * 3; output[baseIndex] = hasCoords ? x : 0; output[baseIndex + 1] = hasCoords ? y : 0; output[baseIndex + 2] = hasCoords ? DEFAULT_SCORE : 0; } return output; }; const people = poseGroups.map((poseKeypoints, index) => { const bodyCount = Array.isArray(poseKeypoints) && poseKeypoints.length > 0 ? poseKeypoints.length : 18; const poseFlat = flattenKeypoints(poseKeypoints, bodyCount); const person = { pose_keypoints_2d: poseFlat }; if (includeExtras && hasNonZeroCoords(faceGroups[index])) { person.face_keypoints_2d = flattenKeypoints(faceGroups[index], FACE_KEYPOINT_COUNT); } if (includeExtras && hasNonZeroCoords(handLeftGroups[index])) { person.hand_left_keypoints_2d = flattenKeypoints(handLeftGroups[index], HAND_KEYPOINT_COUNT); } if (includeExtras && hasNonZeroCoords(handRightGroups[index])) { person.hand_right_keypoints_2d = flattenKeypoints(handRightGroups[index], HAND_KEYPOINT_COUNT); } return person; }); return { canvas_width: width, canvas_height: height, people }; }, serializeJSON() { // Use renderer as single source of truth const includeExtras = typeof this.shouldPreserveExtraKeypoints === "function" ? this.shouldPreserveExtraKeypoints() : false; const payload = this.buildOpenPosePayload(includeExtras); return JSON.stringify(payload); }, save() { // Use renderer as single source of truth const includeExtras = typeof this.shouldPreserveExtraKeypoints === "function" ? this.shouldPreserveExtraKeypoints() : false; const json = JSON.stringify(this.buildOpenPosePayload(includeExtras)); this.node.properties.savedPose = json; const blob = new Blob([json], { type: "application/json" }); var filename = "pose-" + Date.now().toString() + ".json"; var a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); }, loadJSON(text, filename = "", options = {}) { const { silent = false, presetIndex = null } = options; this._initializing = true; let json; try { json = JSON.parse(text); } catch (error) { return "JSON parse error: " + error.message; } const hasEditorKeypoints = json && typeof json === "object" && ("keypoints" in json || "width" in json || "height" in json); if (hasEditorKeypoints) { const width = Number(json["width"]); const height = Number(json["height"]); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { return "Invalid canvas dimensions in pose file (width and height must be positive numbers)."; } } const normalized = normalizePresetData(json, filename); if (!normalized || !Array.isArray(normalized.presets) || normalized.presets.length === 0) { return "No valid poses found in file. Check file format and ensure it contains keypoint data."; } let preset = normalized.presets[0]; if (normalized.presets.length > 1) { let selectedIndex = 0; if (Number.isInteger(presetIndex)) { if (presetIndex < 0 || presetIndex >= normalized.presets.length) { return `Invalid pose index "${presetIndex}".`; } selectedIndex = presetIndex; } preset = normalized.presets[selectedIndex]; } const keypoints = Array.isArray(preset?.keypoints) ? preset.keypoints : []; const faceKeypoints = Array.isArray(preset?.faceKeypoints) ? preset.faceKeypoints : null; const handLeftKeypoints = Array.isArray(preset?.handLeftKeypoints) ? preset.handLeftKeypoints : null; const handRightKeypoints = Array.isArray(preset?.handRightKeypoints) ? preset.handRightKeypoints : null; // Detect format and get keypoint count const detectedFormat = getFormatForPose(keypoints); const kpCount = detectedFormat && detectedFormat.keypoints ? detectedFormat.keypoints.length : 18; if (keypoints.length < kpCount) { return "Pose has insufficient keypoints. Expected " + kpCount + " but got " + keypoints.length + "."; } if (detectedFormat && !isFormatEditAllowed(detectedFormat.id)) { showToast("warn", "Pose Editor", t(`toast.${detectedFormat.id}_edit_disabled`), 7000); return `Editing ${detectedFormat.displayName} poses is not allowed in this extension.`; } const baseWidth = Number(preset.canvas_width) || Number(preset.width) || Number(normalized.baseWidth); const baseHeight = Number(preset.canvas_height) || Number(preset.height) || Number(normalized.baseHeight); if (!Number.isFinite(baseWidth) || !Number.isFinite(baseHeight) || baseWidth <= 0 || baseHeight <= 0) { return "Invalid or missing canvas dimensions in pose file."; } this.resizeCanvas(baseWidth, baseHeight) this.resetCanvas(); let loadedCount = 0; let personIndex = 0; for (let i = 0; i < keypoints.length; i += kpCount) { const group = keypoints.slice(i, i + kpCount); const faceGroup = Array.isArray(faceKeypoints) ? faceKeypoints[personIndex] : null; const handLeftGroup = Array.isArray(handLeftKeypoints) ? handLeftKeypoints[personIndex] : null; const handRightGroup = Array.isArray(handRightKeypoints) ? handRightKeypoints[personIndex] : null; personIndex += 1; if (group.length < kpCount) { continue; } if (!group.some(point => isValidKeypoint(point))) { continue; } this.addPose(group, faceGroup, handLeftGroup, handRightGroup) loadedCount += 1; } if (loadedCount === 0) { return "No valid poses could be loaded from the file. Check if keypoint data is valid."; } // Trigger redraw after loading all poses this.renderer.requestRedraw(); debugLog('[OpenPose Studio] JSON loaded. Total poses:', this.renderer.getPoses().length); this.scheduleCanvasFit(); // Refresh COCO keypoints panel to show the loaded pose format this.refreshCocoKeypointsPanel(); // After initial load, set baseline history and enable undo const state = this.renderer.serialize({ includeExtras: true }); const selection = this.renderer.getSelectedPoseIndex(); const keypointEdits = this.renderer.hasKeypointEdits ? this.renderer.hasKeypointEdits() : false; const snapshot = JSON.stringify({ state, selection, keypointEdits }); this.undo_history = [snapshot]; this.redo_history = []; this.updateUndoButton(); this._initializing = false; this.updateCanvasBoundsWarning(); // Show success feedback only if not silent if (!silent) { const fmtName = detectedFormat && detectedFormat.displayName ? detectedFormat.displayName : "Pose"; showToast("success", "Pose Editor", t("toast.pose_added", { formatName: fmtName })); } return null; }, setBackgroundControlsEnabled(enabled, { mode = true, opacity = true } = {}) { const updateControl = (control) => { if (!control) { return; } control.disabled = !enabled; control.style.opacity = enabled ? "1" : "0.5"; control.style.cursor = enabled ? "" : "not-allowed"; if (control.dataset.sidebarPrevDisabled !== undefined) { control.dataset.sidebarPrevDisabled = enabled ? "0" : "1"; } }; if (mode) { updateControl(this.bgModeSelect); } if (opacity) { updateControl(this.opacitySlider); } }, onLoadBackground(e) { const file = this.bgFileInput.files[0]; if (!file) { return; } const reader = new FileReader(); reader.onload = (event) => { const dataUrl = event.target.result; if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:image/")) { showToast("error", "OpenPose Studio", t("toast.invalid_bg_image")); return; } this.updateSessionBackground({ dataUrl, mode: this.backgroundMode, opacity: this.backgroundOpacity }); const img = new Image(); img.onload = () => { this.backgroundImage = img; this.setBackgroundControlsEnabled(true); this.applyBackground(); }; img.src = dataUrl; }; reader.readAsDataURL(file); }, updateSessionBackground(patch) { const next = { ...(this.getSessionBackground() || {}), ...patch }; const mode = normalizeBackgroundMode(next.mode); const opacity = normalizeBackgroundOpacity(next.opacity); const hasDataUrl = typeof next.dataUrl === "string" && next.dataUrl.startsWith("data:image/"); if (Object.prototype.hasOwnProperty.call(patch || {}, "dataUrl")) { if (hasDataUrl) { const stored = setPersistedSetting(POSE_BG_DATA_URL_KEY, next.dataUrl); if (!stored) { removePersistedSetting(POSE_BG_DATA_URL_KEY); showToast("warn", "Pose Editor", t("toast.bg_too_large")); } } else { removePersistedSetting(POSE_BG_DATA_URL_KEY); } } if (Object.prototype.hasOwnProperty.call(patch || {}, "mode")) { setPersistedSetting(POSE_BG_MODE_KEY, mode); } if (Object.prototype.hasOwnProperty.call(patch || {}, "opacity")) { setPersistedSetting(POSE_BG_OPACITY_KEY, `${opacity}`); } }, getSessionBackground() { const dataUrl = getPersistedSetting(POSE_BG_DATA_URL_KEY, null); const modeRaw = getPersistedSetting(POSE_BG_MODE_KEY, DEFAULT_BG_MODE); const opacityRaw = getPersistedSetting(POSE_BG_OPACITY_KEY, DEFAULT_BG_OPACITY); return { dataUrl: (typeof dataUrl === "string" && dataUrl.startsWith("data:image/")) ? dataUrl : null, mode: normalizeBackgroundMode(modeRaw), opacity: normalizeBackgroundOpacity(opacityRaw) }; }, restoreSessionBackground() { const session = this.getSessionBackground(); if (!session || !session.dataUrl) { return; } this.backgroundMode = session.mode || DEFAULT_BG_MODE; this.backgroundOpacity = typeof session.opacity === "number" ? session.opacity : DEFAULT_BG_OPACITY; if (this.bgModeSelect) { this.bgModeSelect.value = this.backgroundMode; } if (this.opacitySlider) { const pct = Math.round(this.backgroundOpacity * 100); this.opacitySlider.value = `${pct}`; if (this.opacityValue) { this.opacityValue.textContent = `${pct}%`; } } const img = new Image(); img.onload = () => { this.backgroundImage = img; this.setBackgroundControlsEnabled(true); this.applyBackground(); }; img.src = session.dataUrl; }, applyBackground() { if (!this.backgroundImage || !this.renderer) { return; } // Pass background image to renderer with mode and opacity this.renderer.setBackground(this.backgroundImage, this.backgroundMode, this.backgroundOpacity); this.renderer.requestRedraw(); }, applyCanvasBoundsWarningStyles(banner) { if (!banner) return; // Banner container banner.style.display = "none"; // Hidden by default banner.style.marginTop = "8px"; banner.style.flexShrink = "0"; // Content wrapper - match pose merger warning style const content = banner.querySelector(".openpose-canvas-bounds-warning-content"); if (content) { content.style.display = "flex"; content.style.alignItems = "flex-start"; content.style.gap = "12px"; content.style.padding = "12px 16px"; content.style.borderRadius = "6px"; content.style.backgroundColor = "rgba(244, 173, 62, 0.14)"; content.style.border = "1px solid rgba(244, 173, 62, 0.40)"; content.style.backdropFilter = "blur(2px)"; content.style.webkitBackdropFilter = "blur(2px)"; content.style.boxShadow = "0 6px 18px rgba(0,0,0,0.35)"; content.style.boxSizing = "border-box"; content.style.width = "100%"; } // Icon const icon = banner.querySelector(".openpose-canvas-bounds-warning-icon"); if (icon) { icon.style.fontSize = "18px"; icon.style.lineHeight = "1.5"; icon.style.color = "#fff"; icon.style.flexShrink = "0"; icon.style.display = "flex"; icon.style.alignItems = "center"; icon.style.justifyContent = "center"; } // Text container const textDiv = banner.querySelector(".openpose-canvas-bounds-warning-text"); if (textDiv) { textDiv.style.color = "#fff"; textDiv.style.fontSize = "13px"; textDiv.style.lineHeight = "1.5"; textDiv.style.margin = "0"; textDiv.style.flex = "1"; textDiv.style.wordBreak = "break-word"; textDiv.style.overflowWrap = "break-word"; } // Message paragraph const message = banner.querySelector(".openpose-canvas-bounds-warning-message"); if (message) { message.style.margin = "0"; message.style.padding = "0"; message.style.color = "#fff"; message.style.fontSize = "13px"; message.style.lineHeight = "1.5"; } }, updateCanvasBoundsWarning() { const poses = this.renderer ? this.renderer.getPoses() : []; if (!this._canvasBoundsPoseLogged && poses.length > 0) { debugLog("[OpenPose Studio] Canvas bounds sample pose:", poses[0]); this._canvasBoundsPoseLogged = true; } const boundsCheck = isAnyKeypointOutOfBounds(poses, this.canvasWidth, this.canvasHeight); if (this.canvasBoundsWarningBanner) { if (boundsCheck.outOfBounds) { this.canvasBoundsWarningBanner.style.display = "block"; const message = this.canvasBoundsWarningBanner.querySelector(".openpose-canvas-bounds-warning-message"); if (message && boundsCheck.count > 0) { message.textContent = `Canvas size is too small: ${boundsCheck.count} keypoint${boundsCheck.count !== 1 ? 's are' : ' is'} outside the canvas. Increase width/height or reposition poses.`; } } else { this.canvasBoundsWarningBanner.style.display = "none"; } } return boundsCheck; }, clearBackground() { this.backgroundImage = null; this.setBackgroundControlsEnabled(false); removePersistedSetting(POSE_BG_DATA_URL_KEY); removePersistedSetting(POSE_BG_MODE_KEY); removePersistedSetting(POSE_BG_OPACITY_KEY); // Clear background from renderer if (this.renderer) { this.renderer.setBackground(null, DEFAULT_BG_MODE, DEFAULT_BG_OPACITY); } } }; export const poseEditorPresetWorkflow = { populatePresetSelect() { if (!this.presetSelect) { return; } const previous = this.presetSelect.value; this.presetSelect.innerHTML = ""; const groupMap = new Map(); for (const preset of this.presets) { const groupName = preset.group || ""; let container = this.presetSelect; if (groupName) { if (!groupMap.has(groupName)) { const optgroup = document.createElement("optgroup"); optgroup.label = groupName; groupMap.set(groupName, optgroup); this.presetSelect.appendChild(optgroup); } container = groupMap.get(groupName); } const option = document.createElement("option"); option.value = preset.id; // Add warning icon if preset has validation error // Show the preset's `label` as the visible text (keep id as option value) let displayLabel = (preset && preset.label) ? preset.label : this.normalizePoseName(preset.id); if (preset.validationError) { displayLabel = `\u26A0\uFE0F ${displayLabel}`; option.title = `Invalid preset JSON: ${preset.validationError}`; } option.textContent = displayLabel; container.appendChild(option); } // Add invalid file presets if available if (this.emptyPoseFiles && this.emptyPoseFiles.length > 0) { if (!groupMap.has("Invalid Files")) { const optgroup = document.createElement("optgroup"); optgroup.label = "Invalid Files"; groupMap.set("Invalid Files", optgroup); this.presetSelect.appendChild(optgroup); } const invalidGroup = groupMap.get("Invalid Files"); for (const { filename, reason } of this.emptyPoseFiles) { const option = document.createElement("option"); option.value = `invalid:${filename}`; option.textContent = `\u26A0\uFE0F ${filename}`; option.title = `Invalid preset JSON: ${reason}`; invalidGroup.appendChild(option); } } if (previous && this.presetSelect.options.length > 0) { // Check if previous value still exists let found = false; for (const opt of this.presetSelect.options) { if (opt.value === previous) { found = true; break; } } if (found) { this.presetSelect.value = previous; } else { this.presetSelect.value = this.presets[0]?.id || ""; } } else { this.presetSelect.value = this.presets[0]?.id || ""; } this.renderPresetPreview(this.presetSelect.value); }, showPresetsLoadingState() { if (!this.presetSelect) { return; } // Disable the selector and show "Loading..." placeholder this.presetSelect.disabled = true; this.presetSelect.innerHTML = ""; const option = document.createElement("option"); option.value = ""; option.textContent = "Loading..."; option.selected = true; option.disabled = true; this.presetSelect.appendChild(option); }, stepPreset(direction) { if (!this.presetSelect) { return; } const options = this.presetSelect.options; if (!options || options.length === 0) { return; } let nextIndex = this.presetSelect.selectedIndex; if (nextIndex < 0) { nextIndex = 0; } nextIndex += direction; if (nextIndex < 0) { nextIndex = 0; } else if (nextIndex >= options.length) { nextIndex = options.length - 1; } if (nextIndex === this.presetSelect.selectedIndex) { return; } this.presetSelect.selectedIndex = nextIndex; this.presetSelect.dispatchEvent(new Event("change", { bubbles: true })); }, getPresetSourceId(preset) { if (preset?.sourceFile && typeof preset.sourceFile === "string") { return preset.sourceFile; } if (!preset?.id || typeof preset.id !== "string") { return "Default"; } // Derive grouping from the filename embedded in the preset ID // so that a root file (e.g. "wariza.json") and a same-named // directory (e.g. "wariza/") always produce distinct keys. const splitIndex = preset.id.indexOf(":"); if (splitIndex !== -1) { const filename = preset.id.slice(0, splitIndex); const slashIdx = filename.lastIndexOf("/"); if (slashIdx !== -1) { // Subdirectory file: group by directory path return filename.slice(0, slashIdx); } // Root file: group by full filename (includes .json) return filename; } if (preset.group) { return `group:${preset.group}`; } return "Default"; }, renderPresetThumbnail(canvas, keypoints, baseWidth, baseHeight) { if (!canvas || !keypoints) { return; } // Detect format and get keypoint count const detectedFormat = getFormatForPose(keypoints); const kpCount = detectedFormat && detectedFormat.keypoints ? detectedFormat.keypoints.length : 18; if (keypoints.length < kpCount) { return; } const ctx = canvas.getContext("2d"); if (!ctx) { return; } const width = canvas.width; const height = canvas.height; const padding = 10; ctx.clearRect(0, 0, width, height); const previewSurface = this.getPreviewSurfaceFill(); if (previewSurface) { ctx.fillStyle = previewSurface; ctx.fillRect(0, 0, width, height); } // Use preset canvas dimensions when available to center correctly const standardCanvasWidth = Number(baseWidth) || this.presetBaseWidth || 512; const standardCanvasHeight = Number(baseHeight) || this.presetBaseHeight || 768; // Calculate scale to fit entire standard canvas into thumbnail with padding const scale = Math.min( (width - padding * 2) / standardCanvasWidth, (height - padding * 2) / standardCanvasHeight ); // Center the scaled standard canvas in the thumbnail const offsetX = (width - standardCanvasWidth * scale) / 2; const offsetY = (height - standardCanvasHeight * scale) / 2; const lineWidth = Math.max(1, 4 * scale); const radius = Math.max(2, 3 * scale); for (let i = 0; i < keypoints.length; i += kpCount) { const person = keypoints.slice(i, i + kpCount); if (person.length < kpCount) continue; // Use detected format for rendering const personEdges = detectedFormat.skeletonEdges; const personColors = detectedFormat.skeletonColors; const keypointColors = Array.isArray(detectedFormat?.keypointColors) ? detectedFormat.keypointColors : null; const formatKeypoints = Array.isArray(detectedFormat?.keypoints) ? detectedFormat.keypoints : null; for (let j = 0; j < personEdges.length; j++) { const [a, b] = personEdges[j]; const pa = person[a]; const pb = person[b]; if (!isValidKeypoint(pa) || !isValidKeypoint(pb)) continue; const strokeColor = `rgba(${personColors[j].join(", ")}, 0.7)`; drawBoneWithOutline( ctx, pa[0] * scale + offsetX, pa[1] * scale + offsetY, pb[0] * scale + offsetX, pb[1] * scale + offsetY, strokeColor, lineWidth ); } for (let j = 0; j < person.length; j++) { const p = person[j]; if (!isValidKeypoint(p)) continue; const kpColor = (keypointColors && keypointColors[j]) || (formatKeypoints && formatKeypoints[j] && formatKeypoints[j].rgb) || [255, 255, 255]; const fillColor = `rgb(${kpColor.join(", ")})`; drawKeypointWithOutline( ctx, p[0] * scale + offsetX, p[1] * scale + offsetY, fillColor, radius ); } } }, getPresetKeypoints(presetId) { const preset = this.presets.find(item => item.id === presetId) || this.presets[0]; if (!preset) { return cloneKeypoints(DEFAULT_POSE_COCO18.keypoints); } const baseWidth = Number(preset.canvas_width) || Number(preset.width) || this.presetBaseWidth; const baseHeight = Number(preset.canvas_height) || Number(preset.height) || this.presetBaseHeight; return scaleKeypointsToCanvas( preset.keypoints, baseWidth, baseHeight, this.canvasWidth, this.canvasHeight ); }, getPresetFaceKeypoints(presetId) { const preset = this.presets.find(item => item.id === presetId) || this.presets[0]; if (!preset || !Array.isArray(preset.faceKeypoints)) { return null; } const baseWidth = Number(preset.canvas_width) || Number(preset.width) || this.presetBaseWidth; const baseHeight = Number(preset.canvas_height) || Number(preset.height) || this.presetBaseHeight; return preset.faceKeypoints.map((group) => ( Array.isArray(group) ? scaleKeypointsToCanvas( group, baseWidth, baseHeight, this.canvasWidth, this.canvasHeight ) : null )); }, getPresetHandKeypoints(presetId) { const preset = this.presets.find(item => item.id === presetId) || this.presets[0]; if (!preset) { return { left: null, right: null }; } const baseWidth = Number(preset.canvas_width) || Number(preset.width) || this.presetBaseWidth; const baseHeight = Number(preset.canvas_height) || Number(preset.height) || this.presetBaseHeight; const handLeftKeypoints = Array.isArray(preset.handLeftKeypoints) ? preset.handLeftKeypoints.map((group) => ( Array.isArray(group) ? scaleKeypointsToCanvas( group, baseWidth, baseHeight, this.canvasWidth, this.canvasHeight ) : null )) : null; const handRightKeypoints = Array.isArray(preset.handRightKeypoints) ? preset.handRightKeypoints.map((group) => ( Array.isArray(group) ? scaleKeypointsToCanvas( group, baseWidth, baseHeight, this.canvasWidth, this.canvasHeight ) : null )) : null; return { left: handLeftKeypoints, right: handRightKeypoints }; }, getPresetResolution(presetId) { const preset = this.presets.find(item => item.id === presetId) || this.presets[0]; const width = Number(preset?.canvas_width) || Number(preset?.width) || this.presetBaseWidth || DEFAULT_CANVAS_WIDTH; const height = Number(preset?.canvas_height) || Number(preset?.height) || this.presetBaseHeight || DEFAULT_CANVAS_HEIGHT; return { width, height }; }, updatePresetPreviewSize(presetId) { if (!this.previewCanvas) { return; } const cssWidth = Math.max( 120, Math.round(this.previewFrame?.clientWidth || this.previewCanvas.clientWidth || 180) ); let cssHeight = Math.round(this.previewFrame?.clientHeight || 0); if (!cssHeight) { const { width, height } = this.getPresetResolution(presetId); const ratio = width > 0 ? height / width : 1; cssHeight = Math.max(60, Math.round(cssWidth * ratio)); } if (this.previewCanvas.width !== cssWidth || this.previewCanvas.height !== cssHeight) { this.previewCanvas.width = cssWidth; this.previewCanvas.height = cssHeight; } if (!this.previewFrame) { this.previewCanvas.style.height = `${cssHeight}px`; } }, renderPresetPreviewInvalid(filename, reason) { if (!this.previewCtx || !this.previewCanvas) { return; } const cssWidth = Math.max( 120, Math.round(this.previewFrame?.clientWidth || this.previewCanvas.clientWidth || 180) ); let cssHeight = Math.round(this.previewFrame?.clientHeight || 0); if (!cssHeight) { cssHeight = cssWidth; } if (this.previewCanvas.width !== cssWidth || this.previewCanvas.height !== cssHeight) { this.previewCanvas.width = cssWidth; this.previewCanvas.height = cssHeight; } if (!this.previewFrame) { this.previewCanvas.style.height = `${cssHeight}px`; } const ctx = this.previewCtx; const width = this.previewCanvas.width; const height = this.previewCanvas.height; ctx.clearRect(0, 0, width, height); ctx.fillStyle = this.getPreviewSurfaceFill(); ctx.fillRect(0, 0, width, height); // Draw yellow warning sign centered ctx.fillStyle = "#FFD700"; ctx.font = "bold 80px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("\u26A0\uFE0F", width / 2, height / 2); }, updatePreviewInvalidState(isInvalid) { if (!this.previewCanvas) { return; } if (isInvalid) { this.previewCanvas.style.cursor = "not-allowed"; } else { this.previewCanvas.style.cursor = "grab"; } this.previewCanvas.style.border = "1px solid var(--openpose-canvas-border)"; this._presetIsInvalid = isInvalid; }, renderPresetPreviewLoading() { if (!this.previewCtx || !this.previewCanvas) { return; } const cssWidth = Math.max( 120, Math.round(this.previewFrame?.clientWidth || this.previewCanvas.clientWidth || 180) ); let cssHeight = Math.round(this.previewFrame?.clientHeight || 0); if (!cssHeight) { cssHeight = cssWidth; } if (this.previewCanvas.width !== cssWidth || this.previewCanvas.height !== cssHeight) { this.previewCanvas.width = cssWidth; this.previewCanvas.height = cssHeight; } if (!this.previewFrame) { this.previewCanvas.style.height = `${cssHeight}px`; } const ctx = this.previewCtx; const width = this.previewCanvas.width; const height = this.previewCanvas.height; ctx.clearRect(0, 0, width, height); ctx.fillStyle = this.getPreviewSurfaceFill(); ctx.fillRect(0, 0, width, height); // Draw centered hourglass icon (same style as invalid-pose warning icon) ctx.fillStyle = "#999"; ctx.font = "bold 80px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("\u231B", width / 2, height / 2); }, renderPresetPreview(presetId) { if (!this.previewCtx || !this.previewCanvas) { return; } if (this.presetsLoading) { this.renderPresetPreviewLoading(); this.updatePreviewInvalidState(false); return; } // Handle invalid file presets if (presetId && presetId.startsWith("invalid:")) { const filename = presetId.slice("invalid:".length); const emptyFile = this.emptyPoseFiles?.find(f => f.filename === filename); if (emptyFile) { this.renderPresetPreviewInvalid(filename, emptyFile.reason); this.updatePreviewInvalidState(true); } return; } this.updatePreviewInvalidState(false); this.updatePresetPreviewSize(presetId); const preset = this.presets.find(item => item.id === presetId) || this.presets[0]; const keypoints = preset ? preset.keypoints : null; // Detect format and get keypoint count const detectedFormat = getFormatForPose(keypoints || []); const kpCount = detectedFormat && detectedFormat.keypoints ? detectedFormat.keypoints.length : 18; const ctx = this.previewCtx; const width = this.previewCanvas.width; const height = this.previewCanvas.height; const padding = 10; ctx.clearRect(0, 0, width, height); ctx.fillStyle = this.getPreviewSurfaceFill(); ctx.fillRect(0, 0, width, height); if (!keypoints || keypoints.length < kpCount) { return; } let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const point of keypoints) { if (!isValidKeypoint(point)) continue; minX = Math.min(minX, point[0]); maxX = Math.max(maxX, point[0]); minY = Math.min(minY, point[1]); maxY = Math.max(maxY, point[1]); } if (!isFinite(minX) || !isFinite(maxX) || !isFinite(minY) || !isFinite(maxY)) { return; } const boxWidth = Math.max(1, maxX - minX); const boxHeight = Math.max(1, maxY - minY); const scale = Math.min( (width - padding * 2) / boxWidth, (height - padding * 2) / boxHeight ); const offsetX = (width - boxWidth * scale) / 2 - minX * scale; const offsetY = (height - boxHeight * scale) / 2 - minY * scale; const lineWidth = Math.max(1, 4 * scale); const radius = Math.max(2, 3 * scale); for (let i = 0; i < keypoints.length; i += kpCount) { const person = keypoints.slice(i, i + kpCount); if (person.length < kpCount) continue; // Use detected format for rendering const personEdges = detectedFormat.skeletonEdges; const personColors = detectedFormat.skeletonColors; const keypointColors = Array.isArray(detectedFormat?.keypointColors) ? detectedFormat.keypointColors : null; const formatKeypoints = Array.isArray(detectedFormat?.keypoints) ? detectedFormat.keypoints : null; for (let j = 0; j < personEdges.length; j++) { const [a, b] = personEdges[j]; const pa = person[a]; const pb = person[b]; if (!isValidKeypoint(pa) || !isValidKeypoint(pb)) continue; const strokeColor = `rgba(${personColors[j].join(", ")}, 0.7)`; drawBoneWithOutline( ctx, pa[0] * scale + offsetX, pa[1] * scale + offsetY, pb[0] * scale + offsetX, pb[1] * scale + offsetY, strokeColor, lineWidth ); } for (let j = 0; j < person.length; j++) { const p = person[j]; if (!isValidKeypoint(p)) continue; const kpColor = (keypointColors && keypointColors[j]) || (formatKeypoints && formatKeypoints[j] && formatKeypoints[j].rgb) || [255, 255, 255]; const fillColor = `rgb(${kpColor.join(", ")})`; drawKeypointWithOutline( ctx, p[0] * scale + offsetX, p[1] * scale + offsetY, fillColor, radius ); } } }, validatePresetData(keypoints, label = "") { /** * Validate preset keypoint data and return any validation error message. * Returns null if valid, or an error string if invalid. */ if (!Array.isArray(keypoints)) { return "Invalid keypoints format"; } if (keypoints.length === 0) { return "No keypoints found"; } // Detect format to determine expected keypoint count const detectedFormat = getFormatForPose(keypoints); const kpCount = detectedFormat && detectedFormat.keypoints ? detectedFormat.keypoints.length : 18; // Check if keypoints array has valid count if (keypoints.length < kpCount) { return `Expected at least ${kpCount} keypoints, found ${keypoints.length}`; } // Check if any valid keypoints exist in each pose group let hasValidKeypoints = false; for (let i = 0; i < keypoints.length; i += kpCount) { const group = keypoints.slice(i, i + kpCount); if (group.length < kpCount) { continue; } if (group.some(point => isValidKeypoint(point))) { hasValidKeypoints = true; break; } } if (!hasValidKeypoints) { return "No valid keypoints in pose"; } return null; // Valid preset }, getPoseGroups() { if (!this.renderer) { return []; } const poses = this.renderer.getPoses(); return poses.length > 0 ? poses : []; }, getSelectedPoseIndex() { return this.renderer ? this.renderer.getSelectedPoseIndex() : -1; } }; function buildPoseEditorOverlayHtml() { return `