Files
andreszs-ComfyUI-Ultralytic…/js/modules/editor.js
T

3684 lines
134 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
HAND_KEYPOINT_COLORS,
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 */
.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 Ultralytics Studio
export function debugLog(...args) {
if (!isPoseEditorDebugEnabled()) {
return;
}
console.log(...args);
}
export function logLayout(label, data) {
if (!isPoseEditorDebugEnabled()) {
return;
}
console.debug(`[Ultralytics 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" },
{ key: "thumb_cmc" }, { key: "thumb_mcp" }, { key: "thumb_ip" }, { key: "thumb_tip" },
{ key: "index_mcp" }, { key: "index_pip" }, { key: "index_dip" }, { key: "index_tip" },
{ key: "middle_mcp" }, { key: "middle_pip" }, { key: "middle_dip" }, { key: "middle_tip" },
{ key: "ring_mcp" }, { key: "ring_pip" }, { key: "ring_dip" }, { key: "ring_tip" },
{ key: "pinky_mcp" }, { key: "pinky_pip" }, { key: "pinky_dip" }, { key: "pinky_tip" }
];
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";
}
// COCO-17: Neck (index 1) is null or missing
const formatId = (keypoints[1] == null) ? "coco17" : "coco18";
const format = getFormat(formatId);
return format ? `${format.displayName} Keypoints` : "COCO Keypoints";
}
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;
}
export function isLegacyPoseEmpty(poseJson) {
if (!poseJson || typeof poseJson !== "string" || poseJson.trim().length === 0) {
return true;
}
let payload;
try {
payload = JSON.parse(poseJson);
} catch (error) {
return true;
}
if (!payload || typeof payload !== "object") {
return true;
}
const keypoints = payload.keypoints;
if (!Array.isArray(keypoints) || keypoints.length === 0) {
return true;
}
for (const group of keypoints) {
if (!Array.isArray(group)) {
continue;
}
for (const point of group) {
if (!point || point.length < 2) {
continue;
}
const x = Number(point[0]);
const y = Number(point[1]);
if (Number.isFinite(x) && Number.isFinite(y) && (x !== 0 || y !== 0)) {
return false;
}
}
}
return true;
}
/**
* 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) {
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;
}
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",
"Ultralytics 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('[Ultralytics 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",
"Ultralytics 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('[Ultralytics 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('[Ultralytics 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"));
this.recordHistory();
this.saveToNode();
this.refreshCocoKeypointsPanel();
}
} 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 id = 0; id < HAND_KEYPOINT_ROWS.length; id++) {
const definition = HAND_KEYPOINT_ROWS[id];
const isPresent = !!mode.keypoints?.[id];
const isEditable = id !== 0;
const keypointLabel = t(`pose_editor.keypoints.hand.${definition.key}`);
const item = document.createElement("div");
item.className = "openpose-coco-keypoint-item openpose-hand-keypoint-item";
item.dataset.handKeypointId = `${id}`;
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(${HAND_KEYPOINT_COLORS[id].join(", ")})`;
const name = document.createElement("span");
name.className = "openpose-coco-keypoint-name";
name.textContent = keypointLabel;
leftContent.appendChild(swatch);
leftContent.appendChild(name);
const statusIcon = document.createElement("span");
statusIcon.className = "openpose-coco-status-icon openpose-kp-status";
statusIcon.classList.add(isPresent ? "is-present" : (isEditable ? "is-missing" : "is-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 = document.createElement("button");
removeControl.type = "button";
removeControl.className = "openpose-coco-remove-control";
removeControl.textContent = "×";
removeControl.title = t("pose_editor.keypoints.remove_title", { target: keypointLabel });
removeControl.setAttribute("aria-label", removeControl.title);
removeControl.addEventListener("click", (event) => {
event.stopPropagation();
this.renderer?.clearHandEditKeypoint?.(id);
});
rightContent.appendChild(removeControl);
}
item.appendChild(leftContent);
item.appendChild(rightContent);
this.cocoKeypointRowElements.set(id, { item, name, statusIcon });
if (isPresent && isEditable) {
item.addEventListener("mouseenter", () => this.renderer?.setHoveredHandEditKeypointId(id));
item.addEventListener("mouseleave", () => this.renderer?.setHoveredHandEditKeypointId(null));
} else if (!isPresent && isEditable) {
this.setupMissingInsertHandle(
statusIcon,
item,
{ type: "keypoint", keypointId: id },
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 `
<div class="openpose-coco-keypoint-item openpose-sidebar-item">
${icon}
<div class="openpose-sidebar-text">
<span class="openpose-sidebar-title">${text}</span>
</div>
</div>`;
})
.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 `
<div class="openpose-coco-keypoint-item openpose-coco-empty-tab-row openpose-sidebar-item" data-module-tab="${summary.id || ""}">
${icon}
<div class="openpose-sidebar-text">
<span class="openpose-sidebar-title">${title}</span>
<div class="openpose-sidebar-desc">${description}</div>
</div>
</div>`;
})
.join("");
const tabsSectionHtml = tabRowsHtml
? `
<div class="openpose-coco-empty-section-spacer"></div>
${tabRowsHtml}`
: "";
this.cocoKeypointsList.innerHTML = `
<div class="openpose-coco-empty-list openpose-muted-icons">
<div class="openpose-coco-keypoint-item openpose-sidebar-item">
${UiIcons.svg('plus', { size: 14, className: 'openpose-sidebar-icon' })}
<div class="openpose-sidebar-text">
<span class="openpose-sidebar-title">${t("pose_editor.empty.action_insert_preset")}</span>
</div>
</div>
${emptyActionsHtml}
<div class="openpose-coco-keypoint-item openpose-sidebar-item">
${UiIcons.svg('fileJson', { size: 14, className: 'openpose-sidebar-icon' })}
<div class="openpose-sidebar-text">
<span class="openpose-sidebar-title">${t("pose_editor.empty.action_load_json")}</span>
</div>
</div>
${tabsSectionHtml}
</div>
`;
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 = "8px";
}
// 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 : []);
// Detect active format from selection or first pose (format registry is source of truth)
let activeFormat = null;
if (selectedKeypoints) {
activeFormat = getFormatForPose(selectedKeypoints);
} else if (poses.length > 0) {
// Use first pose's format when no selection
activeFormat = getFormatForPose(poses[0].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) => {
if (!keypoints || !keypoints[keypointId]) {
return false;
}
const neighbors = adjacency[keypointId];
if (!neighbors || neighbors.length === 0) {
return true;
}
const currentDepth = depth[keypointId];
if (currentDepth == null) {
return neighbors.every((neighbor) => !keypoints[neighbor]);
}
for (const neighbor of neighbors) {
if (depth[neighbor] != null && depth[neighbor] > currentDepth && keypoints[neighbor]) {
return false;
}
}
return true;
};
const createRemoveControl = () => {
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;
};
// 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;
}
// Determine presence
const keypointId = i;
const isPresent = !isDisabled && displayKeypoints[keypointId] != null;
const isMissing = !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";
const anatomyKey = String(keypoint.name || "").toLowerCase().replace(/ /g, "_");
name.textContent = anatomyKey
? t(`pose_editor.keypoints.body.${anatomyKey}`)
: `Keypoint ${keypointId}`;
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 = 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";
if (isDisabled) {
statusIcon.textContent = "";
statusIcon.classList.add("is-disabled");
} else if (isPresent) {
statusIcon.textContent = "";
statusIcon.classList.add("is-present");
} else {
statusIcon.textContent = "";
statusIcon.classList.add("is-missing");
}
const rightContent = document.createElement("div");
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) {
const removeControl = createRemoveControl();
const keypointLabel = keypoint.name || `Keypoint ${keypointId}`;
const removable = canEdit && isKeypointRemovable(keypointId, activeKeypoints);
if (!canEdit) {
removeControl.dataset.removeDisabled = "1";
removeControl.dataset.removeBaseOpacity = "0.25";
removeControl.style.opacity = "0.25";
removeControl.style.cursor = "not-allowed";
removeControl.title = "Select a pose to edit.";
} else if (!removable) {
removeControl.dataset.removeDisabled = "1";
removeControl.dataset.removeBaseOpacity = "0.35";
removeControl.style.opacity = "0.35";
removeControl.style.cursor = "not-allowed";
removeControl.title = "Remove distal points first.";
} else {
removeControl.dataset.removeDisabled = "0";
removeControl.dataset.removeBaseOpacity = "0.7";
removeControl.style.opacity = "0.7";
removeControl.style.cursor = "pointer";
removeControl.title = `Remove ${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;
}
if (!isKeypointRemovable(keypointId, pose.keypoints)) {
showToast("info", "Pose Editor", t("toast.remove_distal_first"));
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) {
this.setupMissingInsertHandle(
statusIcon,
item,
{ type: "keypoint", keypointId },
keypoint.name || `Keypoint ${keypointId}`,
);
} 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, onRemove }) => {
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";
item.style.cursor = "default";
item.addEventListener("mouseenter", () => {
item.style.backgroundColor = "var(--openpose-primary-hover-bg)";
});
item.addEventListener("mouseleave", () => {
item.style.backgroundColor = "var(--openpose-input-bg)";
});
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 = "36px";
rightContent.style.minWidth = "36px";
rightContent.style.justifyContent = "flex-end";
const removeControl = createRemoveControl();
removeControl.dataset.removeDisabled = "0";
removeControl.dataset.removeBaseOpacity = "0.7";
removeControl.style.opacity = "0.7";
removeControl.style.cursor = "pointer";
removeControl.title = `Remove ${label}`;
removeControl.addEventListener("click", (event) => {
event.stopPropagation();
onRemove();
});
item.appendChild(leftContent);
rightContent.appendChild(removeControl);
item.appendChild(rightContent);
this.cocoKeypointsList.appendChild(item);
};
const addMissingHandRow = (side, label, emoji) => {
const item = document.createElement("div");
item.className = "openpose-coco-keypoint-item openpose-keypoint-missing";
item.style.display = "flex";
item.style.alignItems = "center";
item.style.gap = "6px";
item.style.padding = "3px 6px";
item.style.minHeight = "20px";
const swatch = document.createElement("span");
swatch.className = "openpose-coco-color-swatch";
swatch.textContent = emoji;
const name = document.createElement("span");
name.className = "openpose-coco-keypoint-name";
name.textContent = label;
item.appendChild(swatch);
item.appendChild(name);
const insertHandle = createKeypointStatusIcon("missing");
item.appendChild(insertHandle);
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();
}
}
});
}
if (hasLeftHand) {
addExtraActionRow({
label: t("gallery.details.left_hand"),
emoji: "\u270B",
onRemove: () => {
const activeIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null;
if (activeIndex == null || activeIndex < 0) {
return;
}
const didClear = this.renderer.clearHandLeftKeypoints(activeIndex);
if (didClear) {
showToast("success", "Pose Editor", t("toast.left_hand_removed"));
this.refreshCocoKeypointsPanel();
}
}
});
} else if (selectedPose) addMissingHandRow("left", t("gallery.details.left_hand"), "✋");
if (hasRightHand) {
addExtraActionRow({
label: t("gallery.details.right_hand"),
emoji: "\u{1F91A}",
onRemove: () => {
const activeIndex = this.renderer ? this.renderer.getSelectedPoseIndex() : null;
if (activeIndex == null || activeIndex < 0) {
return;
}
const didClear = this.renderer.clearHandRightKeypoints(activeIndex);
if (didClear) {
showToast("success", "Pose Editor", t("toast.right_hand_removed"));
this.refreshCocoKeypointsPanel();
}
}
});
} else if (selectedPose) addMissingHandRow("right", t("gallery.details.right_hand"), "🤚");
// 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);
statusIcon.classList.remove("is-present", "is-missing", "is-disabled");
statusIcon.classList.add(isPresent ? "is-present" : (isEditable ? "is-missing" : "is-disabled"));
statusIcon.style.opacity = isPresent ? "1" : "0.7";
}
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;
// For displaying keypoint presence, use selected pose only (no fallback to first pose when nothing is selected)
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 = "0.5";
item.classList.add("openpose-keypoint-disabled");
if (statusIcon) {
statusIcon.style.color = "var(--openpose-text)";
statusIcon.style.opacity = "0.7";
statusIcon.textContent = "";
statusIcon.classList.remove("is-present", "is-missing");
statusIcon.classList.add("is-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) {
statusIcon.classList.remove("is-present", "is-missing", "is-disabled");
if (isMissing) {
statusIcon.textContent = "";
statusIcon.classList.add("is-missing");
statusIcon.style.opacity = "0.7";
} else {
statusIcon.textContent = "";
statusIcon.classList.add("is-present");
statusIcon.style.opacity = "1";
}
}
// 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"));
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("[Ultralytics 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("[Ultralytics 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("[Ultralytics 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('[Ultralytics 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", "Ultralytics 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("[Ultralytics 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);
}
},
setConditioningAreas(areas) {
if (this.renderer && typeof this.renderer.setConditioningAreas === "function") {
this.renderer.setConditioningAreas(Array.isArray(areas) ? areas : []);
}
}
};
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 `
<div class="openpose-tab-bar ule-ultralytics-shell-tabs-row ule-ultralytics-modal-titlebar ule-ultralytics-modal-tabs">
<div class="openpose-tab-scroll-shell">
<div class="openpose-tab-scroll-area ule-ultralytics-shell-header-left">
<button class="openpose-tab is-active" data-tab="editor">${t("pose_editor.tab.editor")}</button>
<div class="openpose-tab-modules" data-module-slot="tabs"></div>
</div>
</div>
<div class="ule-ultralytics-shell-drag-handle ule-ultralytics-modal-drag-handle" data-role="drag-handle" aria-hidden="true"></div>
<div class="openpose-tab-controls ule-ultralytics-shell-header-right ule-ultralytics-modal-controls">
<button class="openpose-update-badge" type="button" hidden></button>
<button class="openpose-tab-contribute" data-action="open-about" title="${t("pose_editor.tab.contribute")}" aria-label="${t("pose_editor.tab.contribute")}"><span class="openpose-contribute-label">${t("pose_editor.tab.contribute")}</span><span class="openpose-contribute-icon" aria-hidden="true">💙</span></button>
<button class="openpose-tab-maximize" data-action="toggle-maximize"></button>
<button class="openpose-tab-close" data-action="close-editor">\u{2716}\u{FE0F}</button>
</div>
</div>
<div class="openpose-view-tabs openpose-editor-compact-tabs" role="tablist" aria-label="${t("pose_editor.tab.editor")}">
<button class="openpose-view-tab is-active" type="button" role="tab" aria-selected="true" data-editor-pane-target="canvas" title="${t("gallery.details.canvas")}">${UiIcons.svg('canvas', { size: 16, className: 'openpose-ui-icon' })}<span>${t("gallery.details.canvas")}</span></button>
<button class="openpose-view-tab" type="button" role="tab" aria-selected="false" data-editor-pane-target="pose" title="${t("pose_editor.label.preset")}">${UiIcons.svg('sliders', { size: 16, className: 'openpose-ui-icon' })}<span>${t("pose_editor.label.preset")}</span></button>
<button class="openpose-view-tab" type="button" role="tab" aria-selected="false" data-editor-pane-target="keypoints" title="${t("pose_editor.keypoints.label")}">${UiIcons.svg('layers', { size: 16, className: 'openpose-ui-icon' })}<span>${t("pose_editor.keypoints.label")}</span></button>
</div>
<div class="openpose-main ule-ultralytics-shell-main">
<button class="openpose-btn openpose-undo-btn openpose-canvas-undo-btn" type="button" data-action="undo" title="${t("pose_editor.btn.undo_edit")}" aria-label="${t("pose_editor.btn.undo_edit")}">${UiIcons.svg('undo', { size: 18, className: 'openpose-ui-icon' })}</button>
<div class="openpose-sidebar" data-editor-pane="pose">
<div class="openpose-sidebar-card">
<div class="openpose-preset-header">
<label class="openpose-label">${t("pose_editor.label.preset")}</label>
<div class="openpose-preset-header-controls openpose-preset-iconrow">
<button class="openpose-btn openpose-btn-icon openpose-preset-iconbtn" data-action="preset-prev" title="${t("pose_editor.label.preset_prev")}">${UiIcons.svg('chevronUp', { size: 14, className: 'openpose-ui-icon' })}</button>
<button class="openpose-btn openpose-btn-icon openpose-preset-iconbtn" data-action="preset-next" title="${t("pose_editor.label.preset_next")}">${UiIcons.svg('chevronDown', { size: 14, className: 'openpose-ui-icon' })}</button>
<button class="openpose-btn openpose-btn-icon openpose-preset-iconbtn" data-action="presets-reload" title="${t("pose_editor.label.presets_reload")}">${UiIcons.svg('refresh', { size: 14, className: 'openpose-ui-icon' })}</button>
</div>
</div>
<select class="openpose-input openpose-preset-select"></select>
<div class="openpose-preset-preview-frame">
<canvas class="openpose-preset-preview"></canvas>
</div>
<div class="openpose-action-row">
<button class="openpose-btn openpose-btn-icon" data-action="add" title="${t("pose_editor.btn.add_pose")}">${UiIcons.svg('plus', { size: 14, className: 'openpose-ui-icon' })}</button>
<button class="openpose-btn openpose-btn-icon" data-action="remove" title="${t("pose_editor.btn.remove_pose")}">${UiIcons.svg('minus', { size: 14, className: 'openpose-ui-icon' })}</button>
<button class="openpose-btn openpose-btn-icon" data-action="reset" title="${t("pose_editor.btn.clear_canvas")}">${UiIcons.svg('pencil', { size: 14, className: 'openpose-ui-icon' })}</button>
<button class="openpose-btn openpose-btn-icon openpose-undo-btn" data-action="undo" title="${t("pose_editor.btn.undo_edit")}">${UiIcons.svg('undo', { size: 14, className: 'openpose-ui-icon' })}</button>
</div>
<div class="openpose-section-group">
<div class="openpose-section-header">
<label class="openpose-label">${t("pose_editor.label.json_pose_file")}</label>
<span class="openpose-section-icon">${UiIcons.svg('fileJson', { size: 14, className: 'openpose-ui-icon' })}</span>
</div>
<div class="openpose-save-row">
<button class="openpose-btn" data-action="load">${t("pose_editor.btn.load")}</button>
<button class="openpose-btn" data-action="save">${t("pose_editor.btn.save")}</button>
</div>
</div>
<div class="openpose-section-group">
<div class="openpose-section-header">
<label class="openpose-label">${t("pose_editor.label.canvas_size")}</label>
<span class="openpose-section-icon">${UiIcons.svg('canvas', { size: 14, className: 'openpose-ui-icon' })}</span>
</div>
<div class="openpose-canvas-dimensions-row">
<div class="openpose-canvas-dimension">
<input class="openpose-input openpose-width-input" type="number" min="64" max="4096" step="64" title="${t("pose_editor.input.width")}" />
</div>
<div class="openpose-canvas-dimension">
<input class="openpose-input openpose-height-input" type="number" min="64" max="4096" step="64" title="${t("pose_editor.input.height")}" />
</div>
<button class="openpose-btn openpose-btn-icon" data-action="reset-size" title="${t("pose_editor.btn.reset_size")}">${UiIcons.svg('resetCcw', { size: 14, className: 'openpose-ui-icon' })}</button>
</div>
<div class="openpose-canvas-bounds-warning-banner" style="display: none;">
<div class="openpose-canvas-bounds-warning-content">
<span class="openpose-canvas-bounds-warning-icon">\u{26A0}</span>
<div class="openpose-canvas-bounds-warning-text">
<p class="openpose-canvas-bounds-warning-message">${t("pose_editor.warning.canvas_bounds")}</p>
</div>
</div>
</div>
</div>
<div class="openpose-section-group">
<div class="openpose-section-header">
<label class="openpose-label">${t("pose_editor.label.background")}</label>
<span class="openpose-section-icon">${UiIcons.svg('image', { size: 14, className: 'openpose-ui-icon' })}</span>
</div>
<div class="openpose-bg-buttons-row">
<button class="openpose-btn" data-action="load-bg">${t("pose_editor.btn.load")}</button>
<button class="openpose-btn" data-action="clear-bg">${t("pose_editor.btn.bg_remove")}</button>
<button class="openpose-btn openpose-btn-icon" data-action="toggle-areas" title="${t("pose_editor.areas.toggle.hide_title")}" style="flex: 0 0 auto;">${UiIcons.svg('eye', { size: 14, className: 'openpose-ui-icon' })}</button>
</div>
</div>
<div class="openpose-bg-mode-row">
<label class="openpose-label-inline">${t("pose_editor.label.mode")}</label>
<select class="openpose-input openpose-bg-mode-select">
<option value="contain">${t("pose_editor.mode.contain")}</option>
<option value="cover">${t("pose_editor.mode.cover")}</option>
</select>
</div>
<div class="openpose-opacity-row">
<label class="openpose-label-inline">${t("pose_editor.label.opacity")}</label>
<input class="openpose-opacity-slider" type="range" min="0" max="100" value="50" />
</div>
</div>
</div>
<div class="openpose-sidebar openpose-sidebar-placeholder openpose-sidebar-placeholder-left"></div>
<div class="openpose-canvas-area is-active" data-editor-pane="canvas">
<div class="canvas-container">
<canvas class="openpose-editor-canvas"></canvas>
<div class="canvas-drag-overlay"></div>
</div>
</div>
<div class="openpose-sidebar openpose-sidebar-placeholder openpose-sidebar-placeholder-right"></div>
<div class="openpose-sidebar openpose-sidebar-right" data-editor-pane="keypoints">
<div class="openpose-sidebar-card openpose-coco-keypoints-card">
<div class="openpose-coco-keypoints-header">
<label class="openpose-label openpose-coco-keypoints-label">${t("pose_editor.keypoints.label")}</label>
<span class="openpose-keypoints-help" title="${t("pose_editor.keypoints.perspective_tooltip")}" aria-label="${t("pose_editor.keypoints.perspective_tooltip")}">${UiIcons.svg('info', { size: 14, className: 'openpose-ui-icon' })}</span>
</div>
<div class="openpose-coco-keypoints-list"></div>
${buildDonationFooterHtml()}
</div>
</div>
<div class="openpose-module-slot openpose-module-overlay-slot" data-module-slot="overlay"></div>
<div class="openpose-module-slot openpose-module-panels-slot" data-module-slot="module-panels"></div>
<div class="openpose-editor-actions">
<button class="openpose-btn openpose-primary-action-btn openpose-apply-btn" type="button" data-action="ok" title="${t("pose_editor.btn.apply")}" aria-label="${t("pose_editor.btn.apply")}">${UiIcons.svg('check', { size: 16, className: 'openpose-primary-action-icon' })}<span class="openpose-apply-label">${t("pose_editor.btn.apply")}</span></button>
</div>
</div>
<input class="openpose-file-input" type="file" accept=".json" />
<input class="openpose-bg-file-input" type="file" accept="image/*" />
`;
}
const DEFAULT_TAB_STYLES = {
tabBg: "var(--openpose-panel-bg)",
tabInactiveText: "var(--openpose-text-muted)",
tabActiveText: "var(--openpose-primary-text)",
tabActiveBg: "var(--openpose-primary-bg)",
tabHoverText: "var(--openpose-primary-text)",
tabActiveBorder: "inset 0 -2px 0 var(--openpose-primary-bg)",
tabShadow: "var(--openpose-tab-shadow)"
};
const resolveTabStyles = (tabStyles = {}) => ({
...DEFAULT_TAB_STYLES,
...tabStyles
});
export function applyTabButtonStyles(tabButtons, activeTab, tabStyles = {}) {
(tabButtons || []).forEach((button) => {
if (!button) {
return;
}
const isActive = button.dataset.tab === activeTab;
button.classList.toggle("is-active", isActive);
});
}
const ULTRALYTICS_EDITOR_SHELL_STYLESHEET_ID = "ultralytics-editor-shell-stylesheet";
let ultralyticsEditorShellStylesheetReady = null;
function resolveExtensionAssetUrl(assetName) {
return `/ultralytics/assets/${assetName}`;
}
function ensureUltralyticsEditorShellStylesheet() {
if (ultralyticsEditorShellStylesheetReady) {
return ultralyticsEditorShellStylesheetReady;
}
if (typeof document === "undefined") {
ultralyticsEditorShellStylesheetReady = Promise.resolve();
return ultralyticsEditorShellStylesheetReady;
}
let link = document.getElementById(ULTRALYTICS_EDITOR_SHELL_STYLESHEET_ID);
if (link?.sheet) {
ultralyticsEditorShellStylesheetReady = Promise.resolve();
return ultralyticsEditorShellStylesheetReady;
}
const isNewLink = !link;
if (isNewLink) {
link = document.createElement("link");
link.id = ULTRALYTICS_EDITOR_SHELL_STYLESHEET_ID;
link.rel = "stylesheet";
link.href = resolveExtensionAssetUrl("ultralytics_editor.css");
}
ultralyticsEditorShellStylesheetReady = new Promise((resolve) => {
link.addEventListener("load", resolve, { once: true });
link.addEventListener("error", resolve, { once: true });
});
if (isNewLink) {
document.head.appendChild(link);
}
return ultralyticsEditorShellStylesheetReady;
}
const initialUltralyticsEditorShellStylesheetReady = ensureUltralyticsEditorShellStylesheet();
export function applyPoseEditorStyles(container, options = {}) {
if (!container) {
return;
}
// Inject monochrome SVG icon styles (id-guarded)
ensureOpenPoseInjectedStyles();
ensureUltralyticsEditorShellStylesheet();
const sidebarWidth = Number.isFinite(options.sidebarWidth) ? options.sidebarWidth : 280;
const sidebarMinWidth = Number.isFinite(options.sidebarMinWidth) ? options.sidebarMinWidth : 220;
container.classList.add("ule-ultralytics-modal");
container.style.setProperty("--ule-ultralytics-sidebar-width", `${sidebarWidth}px`);
container.style.setProperty("--ule-ultralytics-sidebar-min-width", `${sidebarMinWidth}px`);
container.querySelectorAll(".openpose-tab").forEach((tab) => {
tab.classList.add("ule-ultralytics-shell-tab");
});
container.querySelectorAll(".openpose-muted-icons span, .openpose-muted-icons .openpose-coco-empty-tab-icon").forEach((icon) => {
const text = icon.textContent || "";
const hasEmoji = text && text.length > 0 && text.length <= 3;
if (hasEmoji || icon.classList.contains("openpose-coco-empty-tab-icon")) {
icon.classList.add("openpose-muted-icon");
}
});
container.querySelectorAll(".openpose-coco-keypoint-item").forEach((item) => {
item.classList.add("openpose-coco-keypoint-row");
});
// --- Keypoint hover logic using hoveredKeypointName ---
if (!container._hoveredKeypointName) container._hoveredKeypointName = null;
}
export const poseEditorOverlay = {
id: "editor",
buildUI: buildPoseEditorOverlayHtml,
applyStyles: applyPoseEditorStyles,
initUI: applyPoseEditorStyles,
applyTabButtonStyles,
stylesReady: initialUltralyticsEditorShellStylesheetReady
};