Files
andreszs-ComfyUI-Ultralytic…/js/utils.js
T

1802 lines
55 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.
/**
* Shared utilities for ComfyUI-Ultralytics-Studio
*/
import {
getFormatForPose,
getFormat,
importPoseKeypoints,
} from "./formats/index.js";
import { t } from "./modules/i18n.js";
// ============================================================================
// Canvas Defaults
// ============================================================================
// Default canvas dimensions
export const DEFAULT_CANVAS_WIDTH = 512;
export const DEFAULT_CANVAS_HEIGHT = 512;
export const DEFAULT_PANEL_CANVAS_WIDTH = 768;
export const DEFAULT_PANEL_CANVAS_HEIGHT = 512;
// ============================================================================
// Static configuration constants (shared)
// ============================================================================
export const BLENDER_GRID_BACKGROUND = "#2b2b2b";
export const BLENDER_GRID_LINE = "#c7c7c7";
export const BLENDER_AXIS_X = "#3fae5b";
export const BLENDER_AXIS_Y = "#d45353";
export const PREVIEW_WIDTH = 200;
export const PREVIEW_HEIGHT = 200;
export const MISSING_KEYPOINT_DRAG_TYPE = "text/openpose-missing-keypoint";
export const MISSING_HAND_DRAG_TYPE = "text/openpose-missing-hand";
export const POSES_LIST_URL = "/ultralytics/poses";
export const POSES_FILE_URL = "/ultralytics/poses/";
export const PREVIEW_OUTLINE_STROKE = "rgba(0,0,0,0.22)";
export const PREVIEW_OUTLINE_FILL = "rgba(0,0,0,0.28)";
export const HAND_EDGES = [
[0, 1], [1, 2], [2, 3], [3, 4],
[0, 5], [5, 6], [6, 7], [7, 8],
[0, 9], [9, 10], [10, 11], [11, 12],
[0, 13], [13, 14], [14, 15], [15, 16],
[0, 17], [17, 18], [18, 19], [19, 20],
[5, 9], [9, 13], [13, 17],
];
export const HAND_LINE_COLOR = [0, 255, 0];
export const HAND_KEYPOINT_COLORS = [
[255, 0, 0],
[100, 0, 0], [150, 0, 0], [200, 0, 0], [255, 0, 0],
[100, 0, 0], [150, 0, 0], [200, 0, 0], [255, 0, 0],
[100, 0, 0], [150, 0, 0], [200, 0, 0], [255, 0, 0],
[100, 0, 0], [150, 0, 0], [200, 0, 0], [255, 0, 0],
[100, 0, 0], [150, 0, 0], [200, 0, 0], [255, 0, 0],
];
export function drawBoneWithOutline(
ctx,
x1,
y1,
x2,
y2,
color,
lineWidth,
outlineWidth = 2,
outlineColor = PREVIEW_OUTLINE_STROKE,
) {
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = outlineColor;
ctx.lineWidth = lineWidth + outlineWidth;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
export function drawKeypointWithOutline(
ctx,
x,
y,
color,
radius,
outlineWidth = 1.2,
outlineColor = PREVIEW_OUTLINE_FILL,
) {
ctx.fillStyle = outlineColor;
ctx.beginPath();
ctx.arc(x, y, radius + outlineWidth, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
export const FALLBACK_COMFY_THEME = {
isLight: false,
theme: "dark",
background: "#202020",
text: "#fff",
menuBg: "#353535",
menuBgSecondary: "#303030",
inputBg: "#222",
inputText: "#ddd",
border: "#4e4e4e",
error: "#ff4444",
rowEven: "#222",
rowOdd: "#353535",
contentHover: "#222"
};
// Default built-in pose presets (COCO formats)
export const DEFAULT_POSE_COCO18 = {
id: "coco-18-openpose",
label: "COCO_18 (OpenPose)",
keypoints: [
[241, 77], [241, 120], [191, 118], [177, 183],
[163, 252], [298, 118], [317, 182], [332, 245],
[225, 241], [213, 359], [215, 454], [270, 240],
[282, 360], [286, 456], [232, 59], [253, 60],
[225, 70], [260, 72]
],
canvas_width: 512,
canvas_height: 512
};
export const DEFAULT_POSE_COCO17 = {
id: "default-coco-18-ultralytics",
label: "COCO_17 (Ultralytics)",
keypoints: [
[241, 77], null, [191, 118], [177, 183], [163, 252], [298, 118], [317, 182], [332, 245],
[225, 241], [213, 359], [215, 454], [270, 240], [282, 360], [286, 456], [232, 59], [253, 60], [225, 70], [260, 72]
],
canvas_width: 512,
canvas_height: 512
};
// ============================================================================
// Format-Aware Skeleton Accessors (delegate to format registry)
// ============================================================================
/**
* Get the appropriate skeleton edges based on the pose format.
* @param {boolean} isCoco17 - True if pose is COCO-17 format
* @returns {Array} Array of [keypoint_a, keypoint_b] pairs
*/
export function getSkeletonEdges(isCoco17 = false) {
const format = getFormat(isCoco17 ? "coco17" : "coco18");
return format.skeletonEdges;
}
/**
* Get the appropriate skeleton edge colors based on the pose format.
* @param {boolean} isCoco17 - True if pose is COCO-17 format
* @returns {Array} Array of [r, g, b] color arrays
*/
export function getSkeletonEdgeColors(isCoco17 = false) {
const format = getFormat(isCoco17 ? "coco17" : "coco18");
return format.skeletonColors;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Show a toast notification using ComfyUI's native Toast API.
* Falls back to console logging for older ComfyUI versions.
* @param {string} severity - "info", "success", "warn", or "error"
* @param {string} summary - Toast title
* @param {string} detail - Toast message body
* @param {number} lifeMs - Duration in milliseconds (default: 4000)
* @returns {boolean} - True if toast was shown, false if fallback was used
*/
export function showToast(severity, summary, detail, lifeMs = 4000) {
const toast = app?.extensionManager?.toast;
if (toast && typeof toast.add === "function") {
toast.add({ severity, summary, detail, life: lifeMs });
return true;
}
// Fallback for older ComfyUI: never crash
const fn =
severity === "error"
? "error"
: severity === "warn"
? "warn"
: severity === "success"
? "log"
: "log";
console[fn](`[Ultralytics Studio] ${summary}: ${detail}`);
return false;
}
/**
* Show a confirmation dialog using ComfyUI's dialog API.
* Falls back to native confirm for older ComfyUI versions.
* @param {string} title - Dialog title
* @param {string} message - Dialog message
* @param {object} options - Additional dialog options
* @returns {Promise<boolean>} - True if confirmed, false otherwise
*/
export async function showConfirm(title, message, options = null) {
const dialog = app?.extensionManager?.dialog;
if (dialog && typeof dialog.confirm === "function") {
try {
const result = await dialog.confirm({
title: title ?? "",
message: message ?? "",
...(options && typeof options === "object" ? options : {}),
});
return !!result;
} catch (error) {
// Fall back to native confirm
}
}
try {
if (typeof window !== "undefined" && typeof window.confirm === "function") {
const textTitle = title ? String(title) : "";
const textMessage = message ? String(message) : "";
const combined =
textTitle && textMessage
? `${textTitle}\n\n${textMessage}`
: textTitle || textMessage;
return !!window.confirm(combined);
}
} catch (error) {
// Ignore fallback errors
}
return false;
}
let choiceDialogId = 0;
export function showChoiceDialog({ host, title = "", message = "", choices = [], closeLabel = "Close" } = {}) {
if (typeof document === "undefined" || !host || !Array.isArray(choices) || choices.length === 0) return Promise.resolve(null);
return new Promise((resolve) => {
const id = ++choiceDialogId;
const previousFocus = document.activeElement;
const backdrop = document.createElement("div");
backdrop.className = "openpose-choice-backdrop";
const dialog = document.createElement("section");
dialog.className = "openpose-choice-dialog";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", `openpose-choice-title-${id}`);
dialog.setAttribute("aria-describedby", `openpose-choice-message-${id}`);
const header = document.createElement("header");
header.className = "openpose-choice-header";
const titleElement = document.createElement("h2");
titleElement.id = `openpose-choice-title-${id}`;
titleElement.className = "openpose-choice-title";
titleElement.textContent = title;
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "openpose-choice-close";
closeButton.textContent = "×";
closeButton.title = closeLabel;
closeButton.setAttribute("aria-label", closeLabel);
header.appendChild(titleElement);
header.appendChild(closeButton);
const messageElement = document.createElement("p");
messageElement.id = `openpose-choice-message-${id}`;
messageElement.className = "openpose-choice-message";
messageElement.textContent = message;
const actions = document.createElement("div");
actions.className = "openpose-choice-actions";
const choiceButtons = choices.map((choice) => {
const button = document.createElement("button");
button.type = "button";
button.className = "openpose-choice-option";
if (choice.icon) {
const icon = document.createElement("span");
icon.className = "openpose-choice-option-icon";
icon.textContent = choice.icon;
icon.setAttribute("aria-hidden", "true");
button.appendChild(icon);
}
const label = document.createElement("span");
label.className = "openpose-choice-option-label";
label.textContent = choice.label;
button.appendChild(label);
actions.appendChild(button);
return button;
});
dialog.appendChild(header);
dialog.appendChild(messageElement);
dialog.appendChild(actions);
backdrop.appendChild(dialog);
host.appendChild(backdrop);
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
backdrop.classList.remove("is-open");
window.setTimeout(() => {
backdrop.remove();
previousFocus?.isConnected && previousFocus.focus?.();
resolve(value);
}, 150);
};
closeButton.addEventListener("click", () => finish(null));
choiceButtons.forEach((button, index) => button.addEventListener("click", () => finish(choices[index].value)));
backdrop.addEventListener("pointerdown", (event) => {
event.stopPropagation();
if (event.target === backdrop) finish(null);
});
backdrop.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "Escape") {
event.preventDefault();
finish(null);
}
}, true);
window.requestAnimationFrame(() => {
backdrop.classList.add("is-open");
choiceButtons[0].focus();
});
});
}
/**
* Copy text to clipboard using Clipboard API with execCommand fallback.
* @param {string} text - Text to copy
* @returns {Promise<boolean>} - True if copied successfully
*/
export async function copyToClipboard(text) {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Fall through to legacy method
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return true;
} catch {
return false;
}
}
export function applySidebarButtonStyles(buttons) {
if (!buttons) {
return;
}
Array.from(buttons).forEach((btn) => {
if (!btn) {
return;
}
btn.classList.add("openpose-sidebar-btn-styled");
});
}
/**
* Show a prompt dialog using ComfyUI's dialog API.
* Falls back to native prompt for older ComfyUI versions.
* @param {string} title - Dialog title
* @param {string} message - Dialog message
* @param {string} defaultValue - Default input value
* @param {object} options - Additional dialog options
* @returns {Promise<string|null>} - Entered value or null if canceled
*/
export async function showPrompt(title, message, defaultValue = "", options = null) {
const dialog = app?.extensionManager?.dialog;
if (dialog && typeof dialog.prompt === "function") {
try {
const result = await dialog.prompt({
title: title ?? "",
message: message ?? "",
defaultValue: defaultValue ?? "",
...(options && typeof options === "object" ? options : {}),
});
if (typeof result === "string") {
return result;
}
if (result === null || result === undefined) {
return null;
}
return String(result);
} catch (error) {
// Fall back to native prompt
}
}
try {
if (typeof window !== "undefined" && typeof window.prompt === "function") {
const textTitle = title ? String(title) : "";
const textMessage = message ? String(message) : "";
const combined =
textTitle && textMessage
? `${textTitle}\n\n${textMessage}`
: textTitle || textMessage;
const response = window.prompt(combined, String(defaultValue ?? ""));
return response === null ? null : String(response);
}
} catch (error) {
// Ignore fallback errors
}
return null;
}
// ============================================================================
// Persistence Helpers
// ============================================================================
const PERSIST_NAMESPACE = "openpose_editor.";
function normalizePersistKey(key) {
if (typeof key !== "string") {
return null;
}
const trimmed = key.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith(PERSIST_NAMESPACE)) {
return trimmed;
}
return `${PERSIST_NAMESPACE}${trimmed}`;
}
function getLocalStorageSafe() {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage || null;
} catch (error) {
return null;
}
}
export function getPersistedSetting(key, defaultValue = null) {
const storage = getLocalStorageSafe();
const normalized = normalizePersistKey(key);
if (!storage || !normalized) {
return defaultValue;
}
try {
const value = storage.getItem(normalized);
return value === null ? defaultValue : value;
} catch (error) {
return defaultValue;
}
}
export function setPersistedSetting(key, value) {
const storage = getLocalStorageSafe();
const normalized = normalizePersistKey(key);
if (!storage || !normalized) {
return false;
}
if (value === null || value === undefined) {
return removePersistedSetting(key);
}
try {
storage.setItem(normalized, String(value));
return true;
} catch (error) {
return false;
}
}
export function removePersistedSetting(key) {
const storage = getLocalStorageSafe();
const normalized = normalizePersistKey(key);
if (!storage || !normalized) {
return false;
}
try {
storage.removeItem(normalized);
return true;
} catch (error) {
return false;
}
}
export function getPersistedJSON(key, defaultValue = null) {
const raw = getPersistedSetting(key, null);
if (raw === null || raw === undefined) {
return defaultValue;
}
try {
return JSON.parse(raw);
} catch (error) {
removePersistedSetting(key);
return defaultValue;
}
}
export function setPersistedJSON(key, obj) {
if (obj === undefined) {
return removePersistedSetting(key);
}
try {
return setPersistedSetting(key, JSON.stringify(obj));
} catch (error) {
return false;
}
}
/**
* Check if a keypoint is present and has valid coordinates
* @param {Object} keypoint - Keypoint object with x, y properties
* @returns {boolean} - True if keypoint is present with valid non-zero coordinates
*/
export function isKeypointPresent(keypoint) {
if (!keypoint) {
return false;
}
// Keypoints are {x, y} objects when present
// Check if it has valid coordinates
if (
typeof keypoint === "object" &&
keypoint.x !== undefined &&
keypoint.y !== undefined
) {
// A keypoint is missing if coordinates are [0, 0]
if (keypoint.x === 0 && keypoint.y === 0) {
return false;
}
return true;
}
return false;
}
/**
* Get display name for a format ID using the format registry.
* @param {string} formatId - Format identifier (e.g. "coco17", "coco18")
* @returns {string} Human-readable format name
*/
export function getFormatDisplayName(formatId) {
const format = getFormat(formatId);
return format ? format.displayName : "COCO Keypoints";
}
export function getComfyThemeSafe() {
if (typeof window === "undefined") {
return FALLBACK_COMFY_THEME;
}
if (typeof window.getComfyTheme === "function") {
return window.getComfyTheme();
}
if (window.ComfyTheme && typeof window.ComfyTheme.getTheme === "function") {
return window.ComfyTheme.getTheme();
}
return FALLBACK_COMFY_THEME;
}
export function getThemeOverlayColor(theme) {
const resolved = theme || getComfyThemeSafe();
return resolved.isLight ? "rgba(0,0,0,0.2)" : "rgba(0,0,0,0.5)";
}
export function parsePosePayload(poseJson) {
if (!poseJson) {
return null;
}
if (typeof poseJson === "string") {
try {
return JSON.parse(poseJson);
} catch {
return null;
}
}
if (typeof poseJson === "object") {
return poseJson;
}
return null;
}
export async function loadImageAsync(imageURL) {
return new Promise((resolve) => {
const e = new Image();
e.setAttribute("crossorigin", "anonymous");
e.addEventListener("load", () => {
resolve(e);
});
e.src = imageURL;
return e;
});
}
export async function dataUrlToBlob(dataUrl) {
const response = await fetch(dataUrl);
return await response.blob();
}
// ============================================================================
// Keypoint Geometry & Transform Utilities
// ============================================================================
/**
* Deep clone array of keypoints (as [x, y] pairs)
* @param {Array} points - Array of [x, y] or null values
* @returns {Array} - Deep cloned keypoint array
*/
export function cloneKeypoints(points) {
return points.map((point) =>
Array.isArray(point) ? [point[0], point[1]] : null,
);
}
/**
* Validate a single keypoint coordinate pair
* @param {Array} point - [x, y] coordinate pair
* @returns {boolean} - True if valid, non-zero coordinates
*/
export function isValidKeypoint(point) {
if (!Array.isArray(point) || point.length < 2) {
return false;
}
const x = Number(point[0]);
const y = Number(point[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return false;
}
// Treat [0, 0] as invalid/missing (upper-body presets)
return !(x === 0 && y === 0);
}
/**
* Calculate bounding box center of valid keypoints
* @param {Array} points - Array of [x, y] coordinate pairs
* @returns {Array|null} - [centerX, centerY] or null if no valid keypoints
*/
export function getKeypointsBoundsCenter(points) {
if (!Array.isArray(points)) {
return null;
}
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const point of points) {
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 null;
}
return [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2];
}
/**
* Scale keypoint coordinates from one canvas size to another
* @param {Array} points - Array of [x, y] coordinate pairs
* @param {number} baseWidth - Original canvas width
* @param {number} baseHeight - Original canvas height
* @param {number} targetWidth - Target canvas width
* @param {number} targetHeight - Target canvas height
* @returns {Array} - Scaled keypoint coordinates
*/
export function scaleKeypointsToCanvas(
points,
baseWidth,
baseHeight,
targetWidth,
targetHeight,
) {
const scaleX = targetWidth / baseWidth;
const scaleY = targetHeight / baseHeight;
return points.map((point) => {
if (!Array.isArray(point) || point.length < 2) {
return null;
}
const x = Number(point[0]);
const y = Number(point[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null;
}
return [Math.round(x * scaleX), Math.round(y * scaleY)];
});
}
/**
* Validate keypoint count matches the selected format's keypoint structure.
* Accepts keypoint arrays where total % format.keypoints.length === 0.
* @param {number} count - Total keypoint count
* @param {string} formatId - Format ID for validation (defaults to auto-detect or coco18)
* @returns {boolean} - True if valid for the format
*/
export function isValidKeypointCount(count, formatId = null) {
if (count === 0) return false;
// If no explicit format, try to auto-detect from count or default to coco18
let kpPerPerson = 18; // Default
if (formatId) {
const format = getFormat(formatId);
if (format && format.keypoints && Array.isArray(format.keypoints)) {
kpPerPerson = format.keypoints.length;
}
} else {
// Auto-detect: if count is divisible by 17 or 18, pick the appropriate one
// Prefer 18 if ambiguous (0 % 17 and 0 % 18 are same for lcm)
if (count % 17 === 0 && count % 18 === 0) {
kpPerPerson = 18; // Default to COCO-18
} else if (count % 17 === 0) {
kpPerPerson = 17; // Pure COCO-17 (or multiples)
} else if (count % 18 === 0) {
kpPerPerson = 18; // Pure COCO-18
} else {
return false; // Not divisible by either
}
}
return count % kpPerPerson === 0;
}
// ============================================================================
// Pose Data Normalization
// ============================================================================
/**
* Extract and remap keypoints from a flattened pose_keypoints_2d array.
* Handles both COCO-17 (51 values) and COCO-18 (54 values) formats.
* COCO-17 keypoints are remapped into COCO-18 index positions.
* Coordinates in [0,1] range are denormalized to pixel values.
*
* Delegates to the format registry for format-specific import logic.
*
* @param {Array} poseKeypoints2d - Flat array of [x, y, confidence, ...] values
* @param {number} canvasWidth - Canvas width for denormalization
* @param {number} canvasHeight - Canvas height for denormalization
* @returns {Array|null} - 18-element [x,y] keypoint array, or null if invalid
*/
export function extractKeypointsFromPoseKeypoints2d(
poseKeypoints2d,
canvasWidth,
canvasHeight,
) {
if (!Array.isArray(poseKeypoints2d)) {
return null;
}
if (poseKeypoints2d.length % 3 === 0) {
return importPoseKeypoints(poseKeypoints2d, canvasWidth, canvasHeight);
}
if (poseKeypoints2d.length % 2 === 0) {
const epsilon = 0.5;
const withConf = [];
for (let i = 0; i < poseKeypoints2d.length; i += 2) {
const x = Number(poseKeypoints2d[i]);
const y = Number(poseKeypoints2d[i + 1]);
const conf =
Number.isFinite(x)
&& Number.isFinite(y)
&& (Math.abs(x) > epsilon || Math.abs(y) > epsilon)
? 1
: 0;
withConf.push(x, y, conf);
}
return importPoseKeypoints(withConf, canvasWidth, canvasHeight);
}
return null;
}
function normalizeExtraKeypointsGroup(points, canvasWidth, canvasHeight) {
const epsilon = 0.5;
if (!Array.isArray(points) || points.length === 0) {
return null;
}
const normalized = [];
for (const point of points) {
if (!Array.isArray(point) || point.length < 2) {
normalized.push(null);
continue;
}
const x = Number(point[0]);
const y = Number(point[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
normalized.push(null);
continue;
}
let finalX;
let finalY;
if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
finalX = Math.round(x * canvasWidth);
finalY = Math.round(y * canvasHeight);
} else {
finalX = Math.round(x);
finalY = Math.round(y);
}
if (Math.abs(finalX) <= epsilon && Math.abs(finalY) <= epsilon) {
normalized.push(null);
continue;
}
normalized.push([finalX, finalY]);
}
return normalized.length ? normalized : null;
}
function extractExtraKeypointsFromKeypoints2d(
extraKeypoints2d,
canvasWidth,
canvasHeight,
) {
const epsilon = 0.5;
if (!Array.isArray(extraKeypoints2d) || extraKeypoints2d.length < 3) {
return null;
}
let step = 3;
if (extraKeypoints2d.length % 3 === 0) {
step = 3;
} else if (extraKeypoints2d.length % 2 === 0) {
step = 2;
} else {
return null;
}
const points = [];
for (let i = 0; i < extraKeypoints2d.length; i += step) {
const x = Number(extraKeypoints2d[i]);
const y = Number(extraKeypoints2d[i + 1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
points.push(null);
continue;
}
let finalX;
let finalY;
if (x >= 0 && x <= 1 && y >= 0 && y <= 1) {
finalX = Math.round(x * canvasWidth);
finalY = Math.round(y * canvasHeight);
} else {
finalX = Math.round(x);
finalY = Math.round(y);
}
if (Math.abs(finalX) <= epsilon && Math.abs(finalY) <= epsilon) {
points.push(null);
continue;
}
points.push([finalX, finalY]);
}
return points.length ? points : null;
}
function extractFaceKeypointsFromFaceKeypoints2d(
faceKeypoints2d,
canvasWidth,
canvasHeight,
) {
return extractExtraKeypointsFromKeypoints2d(
faceKeypoints2d,
canvasWidth,
canvasHeight,
);
}
function extractHandKeypointsFromHandKeypoints2d(
handKeypoints2d,
canvasWidth,
canvasHeight,
) {
return extractExtraKeypointsFromKeypoints2d(
handKeypoints2d,
canvasWidth,
canvasHeight,
);
}
function normalizeExtraGroupsFromPayload(extraGroups, canvasWidth, canvasHeight) {
if (!Array.isArray(extraGroups) || extraGroups.length === 0) {
return null;
}
if (typeof extraGroups[0] === "number") {
return [
extractExtraKeypointsFromKeypoints2d(
extraGroups,
canvasWidth,
canvasHeight,
),
];
}
return extraGroups.map((group) =>
normalizeExtraKeypointsGroup(group, canvasWidth, canvasHeight),
);
}
function hasExtraKeypoints(groups) {
return (
Array.isArray(groups) &&
groups.some((group) => Array.isArray(group) && group.length > 0)
);
}
function normalizeLegacyKeypoint(point) {
if (Array.isArray(point) && point.length >= 2) {
const x = Number(point[0]);
const y = Number(point[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null;
}
return [x, y];
}
if (point && typeof point === "object") {
const x = Number(point.x);
const y = Number(point.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return null;
}
return [x, y];
}
return null;
}
function normalizeLegacyKeypointsGroup(group) {
if (!Array.isArray(group) || group.length === 0) {
return null;
}
if (group.length !== 17 && group.length !== 18) {
return null;
}
const normalized = group.map((point) => normalizeLegacyKeypoint(point));
if (group.length === 17) {
const format = getFormat("coco17");
if (format && typeof format.normalizePose === "function") {
const remapped = format.normalizePose(normalized);
return Array.isArray(remapped) ? remapped : null;
}
return null;
}
return normalized;
}
function normalizeLegacyKeypointsGroups(keypoints) {
if (!Array.isArray(keypoints) || keypoints.length === 0) {
return [];
}
const first = keypoints[0];
const isNested =
Array.isArray(first) &&
(Array.isArray(first[0]) || first[0] === null || typeof first[0] === "object");
if (isNested) {
return keypoints;
}
const flat = keypoints;
let groupSize = null;
if (flat.length > 18 && flat.length % 18 === 0) {
groupSize = 18;
} else if (flat.length > 17 && flat.length % 17 === 0) {
groupSize = 17;
}
if (groupSize) {
const groups = [];
for (let i = 0; i < flat.length; i += groupSize) {
groups.push(flat.slice(i, i + groupSize));
}
return groups;
}
return [flat];
}
function normalizeLegacyExtraGroups(extraGroups, canvasWidth, canvasHeight) {
if (!Array.isArray(extraGroups) || extraGroups.length === 0) {
return null;
}
if (typeof extraGroups[0] === "number") {
return [
extractExtraKeypointsFromKeypoints2d(
extraGroups,
canvasWidth,
canvasHeight,
),
];
}
const first = extraGroups[0];
const isNested =
Array.isArray(first) &&
(Array.isArray(first[0]) || first[0] === null || typeof first[0] === "object");
const groups = isNested ? extraGroups : [extraGroups];
return groups.map((group) =>
normalizeExtraKeypointsGroup(group, canvasWidth, canvasHeight),
);
}
/**
* Normalize pose JSON (STANDARD and LEGACY) into a canonical representation.
*
* STANDARD schema:
* { canvas_width, canvas_height, people: [{ pose_keypoints_2d, face_keypoints_2d?, hand_*_keypoints_2d? }] }
*
* LEGACY schema:
* { width, height, format?, keypoints: [[x,y], ...] or [[...], [...]], face_keypoints_2d?, hand_*_keypoints_2d? }
*
* @param {string|object} raw - JSON string or parsed object
* @returns {object|null} - { width, height, poses: [{ keypoints, faceKeypoints?, handLeftKeypoints?, handRightKeypoints? }] }
*/
export function normalizePoseJson(raw) {
let data = raw;
if (typeof raw === "string") {
try {
data = JSON.parse(raw);
} catch {
return null;
}
}
if (!data || typeof data !== "object") {
return null;
}
// Unwrap single-element array wrapping a dict (e.g. [{...}] -> {...})
if (Array.isArray(data) && data.length === 1 && data[0] !== null && typeof data[0] === "object" && !Array.isArray(data[0])) {
data = data[0];
}
if (Array.isArray(data.people)) {
const width = Number(data.canvas_width) || DEFAULT_CANVAS_WIDTH;
const height = Number(data.canvas_height) || DEFAULT_CANVAS_HEIGHT;
const poses = [];
for (const person of data.people) {
if (!person || !Array.isArray(person.pose_keypoints_2d)) {
continue;
}
const keypoints = extractKeypointsFromPoseKeypoints2d(
person.pose_keypoints_2d,
width,
height,
);
if (!Array.isArray(keypoints) || keypoints.length === 0) {
continue;
}
const pose = { keypoints };
const face = extractFaceKeypointsFromFaceKeypoints2d(
person.face_keypoints_2d,
width,
height,
);
const leftHand = extractHandKeypointsFromHandKeypoints2d(
person.hand_left_keypoints_2d,
width,
height,
);
const rightHand = extractHandKeypointsFromHandKeypoints2d(
person.hand_right_keypoints_2d,
width,
height,
);
if (Array.isArray(face)) {
pose.faceKeypoints = face;
}
if (Array.isArray(leftHand)) {
pose.handLeftKeypoints = leftHand;
}
if (Array.isArray(rightHand)) {
pose.handRightKeypoints = rightHand;
}
poses.push(pose);
}
if (poses.length === 0) {
return null;
}
return { width, height, poses };
}
if (Array.isArray(data.keypoints)) {
const width = Number(data.width) || DEFAULT_CANVAS_WIDTH;
const height = Number(data.height) || DEFAULT_CANVAS_HEIGHT;
const poseGroups = normalizeLegacyKeypointsGroups(data.keypoints);
const faceGroups = normalizeLegacyExtraGroups(
data.face_keypoints_2d,
width,
height,
);
const handLeftGroups = normalizeLegacyExtraGroups(
data.hand_left_keypoints_2d,
width,
height,
);
const handRightGroups = normalizeLegacyExtraGroups(
data.hand_right_keypoints_2d,
width,
height,
);
const poses = [];
for (let i = 0; i < poseGroups.length; i += 1) {
const group = poseGroups[i];
const keypoints = normalizeLegacyKeypointsGroup(group);
if (!Array.isArray(keypoints) || keypoints.length === 0) {
continue;
}
const pose = { keypoints };
const faceGroup = Array.isArray(faceGroups) ? faceGroups[i] : null;
const leftHandGroup = Array.isArray(handLeftGroups) ? handLeftGroups[i] : null;
const rightHandGroup = Array.isArray(handRightGroups) ? handRightGroups[i] : null;
if (Array.isArray(faceGroup)) {
pose.faceKeypoints = faceGroup;
}
if (Array.isArray(leftHandGroup)) {
pose.handLeftKeypoints = leftHandGroup;
}
if (Array.isArray(rightHandGroup)) {
pose.handRightKeypoints = rightHandGroup;
}
poses.push(pose);
}
if (poses.length === 0) {
return null;
}
return { width, height, poses };
}
return null;
}
/**
* Attempt to normalize arbitrary pose JSON to canonical Ultralytics COCO-17 editor format.
*
* If the input is already COCO-17 (or the legacy editor format), returns it unchanged.
* If the input is COCO-18 / OpenPose style (54 values per person), converts each person's
* pose_keypoints_2d to COCO-17 native order (51 values), drops Neck, then re-serializes to
* the editor's legacy keypoints format so that the preview, widget, and editor all agree.
*
* @param {string} rawJson - JSON string to normalize
* @returns {{ json: string, converted: boolean }} normalized JSON string and whether a real
* COCO-18 → COCO-17 conversion was performed
*/
export function normalizePoseJsonToCoco17(rawJson) {
if (!rawJson || typeof rawJson !== "string" || rawJson.trim() === "") {
return { json: rawJson, converted: false };
}
let data;
try {
data = JSON.parse(rawJson);
} catch {
return { json: rawJson, converted: false };
}
// Unwrap single-element list
if (Array.isArray(data) && data.length === 1 && data[0] !== null && typeof data[0] === "object" && !Array.isArray(data[0])) {
data = data[0];
}
// If already in legacy editor format (has `keypoints` array), run through normalization
// to detect whether it's already COCO-17 or needs conversion.
if (Array.isArray(data.keypoints)) {
// Already editor format — check format via normalization
const normalized = normalizePoseJson(data);
if (!normalized) return { json: rawJson, converted: false };
// If any pose has Neck (index 1) present, this is a COCO-18 legacy payload — convert.
const hasCoco18 = normalized.poses.some(p => p.keypoints && p.keypoints[1] != null);
if (!hasCoco18) return { json: rawJson, converted: false };
// Fall through to conversion below via standard schema path after re-encoding as POSE_KEYPOINT
const width = normalized.width;
const height = normalized.height;
const people = normalized.poses.map(pose => ({
pose_keypoints_2d: flattenPoseKeypoints18(pose.keypoints),
}));
data = { canvas_width: width, canvas_height: height, people };
}
// POSE_KEYPOINT standard schema: { canvas_width, canvas_height, people }
if (!Array.isArray(data.people) || data.people.length === 0) {
return { json: rawJson, converted: false };
}
const canvasWidth = Number(data.canvas_width) || 512;
const canvasHeight = Number(data.canvas_height) || 512;
// Check if any person is COCO-18 (54 values = 18*3)
const hasCoco18Person = data.people.some(
p => p && Array.isArray(p.pose_keypoints_2d) && p.pose_keypoints_2d.length === 54,
);
if (!hasCoco18Person) {
return { json: rawJson, converted: false };
}
// coco17ToCoco18 mapping (same as Python and coco17.js)
const coco17ToCoco18 = [0, 15, 14, 17, 16, 5, 2, 6, 3, 7, 4, 11, 8, 12, 9, 13, 10];
const poseGroups = [];
for (const person of data.people) {
if (!person || !Array.isArray(person.pose_keypoints_2d)) continue;
const kps = person.pose_keypoints_2d;
let normalized17;
if (kps.length === 54) {
// Parse 18 triplets
const raw18 = [];
for (let i = 0; i < 54; i += 3) {
raw18.push([kps[i], kps[i + 1], kps[i + 2]]);
}
// Remap to COCO-17 native order, then into 18-slot internal space
const internal18 = new Array(18).fill(null);
for (let coco17Idx = 0; coco17Idx < 17; coco17Idx++) {
const coco18Idx = coco17ToCoco18[coco17Idx];
const [x, y, conf] = raw18[coco18Idx];
if (conf > 0 && Number.isFinite(Number(x)) && Number.isFinite(Number(y))) {
let finalX = Number(x);
let finalY = Number(y);
if (finalX >= 0 && finalX <= 1 && finalY >= 0 && finalY <= 1) {
finalX = Math.round(finalX * canvasWidth);
finalY = Math.round(finalY * canvasHeight);
} else {
finalX = Math.round(finalX);
finalY = Math.round(finalY);
}
internal18[coco18Idx] = [finalX, finalY];
}
}
// Neck (index 1) stays null
internal18[1] = null;
normalized17 = internal18;
} else if (kps.length === 51) {
// Already COCO-17 — import normally
normalized17 = importPoseKeypoints(kps, canvasWidth, canvasHeight);
} else {
continue;
}
if (Array.isArray(normalized17)) poseGroups.push(normalized17);
}
if (poseGroups.length === 0) return { json: rawJson, converted: false };
// Serialize as legacy editor format: { width, height, keypoints: [group, ...] }
const editorPayload = {
width: canvasWidth,
height: canvasHeight,
format: "coco17",
keypoints: poseGroups,
};
return { json: JSON.stringify(editorPayload), converted: true };
}
/**
* Flatten an 18-slot internal keypoint array to [x, y, conf, ...] (54 values).
* Used internally by normalizePoseJsonToCoco17.
* @param {Array} keypoints - 18-slot array of [x,y] or null
* @returns {Array} flat array
*/
function flattenPoseKeypoints18(keypoints) {
const out = [];
for (const kp of keypoints) {
if (kp && kp.length >= 2) {
out.push(Number(kp[0]), Number(kp[1]), 1.0);
} else {
out.push(0, 0, 0);
}
}
return out;
}
/**
* Normalize preset data from various JSON formats into a standard structure.
*
* Supported formats:
* 1. POSE_KEYPOINT format: { people: [{pose_keypoints_2d: [...]}], canvas_width, canvas_height }
* 2. Editor export format: { width, height, keypoints: [...] }
* 3. Category dictionary: { "pose-name": { canvas_width, canvas_height, people: [...] }, ... }
*
* @param {object} payload - The JSON payload
* @param {string} filename - The filename (used for generating IDs)
* @returns {object|null} - Normalized data with { presets, baseWidth, baseHeight, format }
*/
export function normalizePresetData(payload, filename = "") {
if (!payload || typeof payload !== "object") {
return null;
}
// POSE_KEYPOINT format: { people: [{pose_keypoints_2d: [...]}], canvas_width, canvas_height }
if (Array.isArray(payload.people) && payload.people.length > 0) {
const baseWidth = Number(payload.canvas_width) || 512;
const baseHeight = Number(payload.canvas_height) || 512;
const baseName = filename.replace(/\.json$/i, "").replace(/^.*[/\\]/, "");
// Extract keypoints from all people
const allKeypoints = [];
const faceKeypoints = [];
const handLeftKeypoints = [];
const handRightKeypoints = [];
for (const person of payload.people) {
if (person && Array.isArray(person.pose_keypoints_2d)) {
const kps = extractKeypointsFromPoseKeypoints2d(
person.pose_keypoints_2d,
baseWidth,
baseHeight,
);
if (kps) {
allKeypoints.push(...kps);
const face = extractFaceKeypointsFromFaceKeypoints2d(
person.face_keypoints_2d,
baseWidth,
baseHeight,
);
const leftHand = extractHandKeypointsFromHandKeypoints2d(
person.hand_left_keypoints_2d,
baseWidth,
baseHeight,
);
const rightHand = extractHandKeypointsFromHandKeypoints2d(
person.hand_right_keypoints_2d,
baseWidth,
baseHeight,
);
faceKeypoints.push(face);
handLeftKeypoints.push(leftHand);
handRightKeypoints.push(rightHand);
}
}
}
if (allKeypoints.length > 0 && isValidKeypointCount(allKeypoints.length)) {
const preset = {
id: baseName,
label: baseName.replace(/[_-]/g, " "),
keypoints: allKeypoints,
canvas_width: baseWidth,
canvas_height: baseHeight,
};
if (hasExtraKeypoints(faceKeypoints)) {
preset.faceKeypoints = faceKeypoints;
}
if (hasExtraKeypoints(handLeftKeypoints)) {
preset.handLeftKeypoints = handLeftKeypoints;
}
if (hasExtraKeypoints(handRightKeypoints)) {
preset.handRightKeypoints = handRightKeypoints;
}
const presets = [preset];
return { presets, baseWidth, baseHeight, format: "pose_keypoint" };
}
}
// Editor export format: { width, height, keypoints }
if (Array.isArray(payload.keypoints)) {
const baseWidth = Number(payload.width) || 512;
const baseHeight = Number(payload.height) || 512;
const baseName = filename.replace(/\.json$/i, "").replace(/^.*[/\\]/, "");
const keypoints = payload.keypoints;
let flatKeypoints = [];
if (
keypoints.length > 0 &&
Array.isArray(keypoints[0]) &&
(Array.isArray(keypoints[0][0]) || keypoints[0][0] === null)
) {
for (const group of keypoints) {
if (Array.isArray(group)) {
flatKeypoints.push(...group);
}
}
} else {
flatKeypoints = keypoints;
}
if (
flatKeypoints.length > 0 &&
isValidKeypointCount(flatKeypoints.length)
) {
const preset = {
id: baseName || "pose",
label: (baseName || "pose").replace(/[_-]/g, " "),
keypoints: flatKeypoints,
canvas_width: baseWidth,
canvas_height: baseHeight,
};
const faceGroups = normalizeExtraGroupsFromPayload(
payload.face_keypoints_2d,
baseWidth,
baseHeight,
);
const handLeftGroups = normalizeExtraGroupsFromPayload(
payload.hand_left_keypoints_2d,
baseWidth,
baseHeight,
);
const handRightGroups = normalizeExtraGroupsFromPayload(
payload.hand_right_keypoints_2d,
baseWidth,
baseHeight,
);
if (hasExtraKeypoints(faceGroups)) {
preset.faceKeypoints = faceGroups;
}
if (hasExtraKeypoints(handLeftGroups)) {
preset.handLeftKeypoints = handLeftGroups;
}
if (hasExtraKeypoints(handRightGroups)) {
preset.handRightKeypoints = handRightGroups;
}
const presets = [preset];
return { presets, baseWidth, baseHeight, format: "editor" };
}
}
// Category format: { "pose-name": { canvas_width, canvas_height, people: [...] }, ... }
const keys = Object.keys(payload);
if (keys.length > 0) {
const presets = [];
let baseWidth = 512;
let baseHeight = 512;
let firstPose = true;
for (const key of keys) {
const pose = payload[key];
if (!pose || typeof pose !== "object") continue;
const poseWidth = Number(pose.canvas_width) || 512;
const poseHeight = Number(pose.canvas_height) || 512;
const people = Array.isArray(pose.people)
? pose.people
: Array.isArray(pose.pose_keypoints_2d)
? [
{
pose_keypoints_2d: pose.pose_keypoints_2d,
face_keypoints_2d: pose.face_keypoints_2d,
hand_left_keypoints_2d: pose.hand_left_keypoints_2d,
hand_right_keypoints_2d: pose.hand_right_keypoints_2d,
},
]
: [];
if (people.length === 0) continue;
// Use first pose's dimensions as base
if (firstPose) {
baseWidth = poseWidth;
baseHeight = poseHeight;
firstPose = false;
}
const allKeypoints = [];
const faceKeypoints = [];
const handLeftKeypoints = [];
const handRightKeypoints = [];
for (const person of people) {
if (!person || !Array.isArray(person.pose_keypoints_2d)) continue;
const kps = extractKeypointsFromPoseKeypoints2d(
person.pose_keypoints_2d,
poseWidth,
poseHeight,
);
if (kps) {
allKeypoints.push(...kps);
const face = extractFaceKeypointsFromFaceKeypoints2d(
person.face_keypoints_2d,
poseWidth,
poseHeight,
);
const leftHand = extractHandKeypointsFromHandKeypoints2d(
person.hand_left_keypoints_2d,
poseWidth,
poseHeight,
);
const rightHand = extractHandKeypointsFromHandKeypoints2d(
person.hand_right_keypoints_2d,
poseWidth,
poseHeight,
);
faceKeypoints.push(face);
handLeftKeypoints.push(leftHand);
handRightKeypoints.push(rightHand);
}
}
if (
allKeypoints.length === 0 ||
!isValidKeypointCount(allKeypoints.length)
)
continue;
const preset = {
id: key,
label: key.replace(/[_-]/g, " "),
keypoints: allKeypoints,
canvas_width: poseWidth || baseWidth,
canvas_height: poseHeight || baseHeight,
};
if (hasExtraKeypoints(faceKeypoints)) {
preset.faceKeypoints = faceKeypoints;
}
if (hasExtraKeypoints(handLeftKeypoints)) {
preset.handLeftKeypoints = handLeftKeypoints;
}
if (hasExtraKeypoints(handRightKeypoints)) {
preset.handRightKeypoints = handRightKeypoints;
}
presets.push(preset);
}
if (presets.length > 0) {
return { presets, baseWidth, baseHeight, format: "dictionary" };
}
}
return null;
}
/**
* Read a File object as text using FileReader.
* @param {File} file - File to read
* @returns {Promise<string>} - File contents as text
*/
export function readFileToText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = () => {
reject(reader.error);
};
reader.readAsText(file);
});
}
// ============================================================================
// Color Utilities
// ============================================================================
/**
* Determine if a color is light or dark based on luminance
* Supports hex (#fff, #ffffff, #ffffffff) and rgb/rgba formats
* @param {string} color - Color string (hex or rgb/rgba)
* @returns {boolean} - True if color is light (luminance > 0.6)
*/
export function isColorLight(color) {
if (!color || typeof color !== "string") {
return false;
}
const value = color.trim();
let r = null;
let g = null;
let b = null;
if (value.startsWith("#")) {
const hex = value.slice(1);
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16);
g = parseInt(hex[1] + hex[1], 16);
b = parseInt(hex[2] + hex[2], 16);
} else if (hex.length === 6 || hex.length === 8) {
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
}
} else if (value.startsWith("rgb")) {
const match = value.match(/rgba?\(([^)]+)\)/i);
if (match) {
const parts = match[1].split(",").map((part) => part.trim());
if (parts.length >= 3) {
r = Number(parts[0]);
g = Number(parts[1]);
b = Number(parts[2]);
}
}
}
if (![r, g, b].every((v) => Number.isFinite(v))) {
return false;
}
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luminance > 0.6;
}
/**
* Adjust color brightness by adding/subtracting a value from RGB components
* Clamps results to [0, 255] range. Preserves alpha channel if present.
* @param {string} color - Color string (hex or rgb/rgba)
* @param {number} amount - Amount to adjust (-255 to 255)
* @returns {string} - Adjusted color in rgb/rgba format, or original color if invalid
*/
export function adjustColor(color, amount) {
if (!color || typeof color !== "string") {
return color;
}
const value = color.trim();
let r = null;
let g = null;
let b = null;
let alpha = null;
if (value.startsWith("#")) {
const hex = value.slice(1);
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16);
g = parseInt(hex[1] + hex[1], 16);
b = parseInt(hex[2] + hex[2], 16);
} else if (hex.length === 6 || hex.length === 8) {
r = parseInt(hex.slice(0, 2), 16);
g = parseInt(hex.slice(2, 4), 16);
b = parseInt(hex.slice(4, 6), 16);
if (hex.length === 8) {
alpha = parseInt(hex.slice(6, 8), 16) / 255;
}
}
} else if (value.startsWith("rgb")) {
const match = value.match(/rgba?\(([^)]+)\)/i);
if (match) {
const parts = match[1].split(",").map((part) => part.trim());
if (parts.length >= 3) {
r = Number(parts[0]);
g = Number(parts[1]);
b = Number(parts[2]);
if (parts.length >= 4) {
alpha = Number(parts[3]);
}
}
}
}
if (![r, g, b].every((v) => Number.isFinite(v))) {
return color;
}
const clamp = (value) => Math.max(0, Math.min(255, Math.round(value)));
const rr = clamp(r + amount);
const gg = clamp(g + amount);
const bb = clamp(b + amount);
if (Number.isFinite(alpha)) {
return `rgba(${rr}, ${gg}, ${bb}, ${alpha})`;
}
return `rgb(${rr}, ${gg}, ${bb})`;
}
// Convert a theme color string into an rgba() string with a forced alpha.
export function toRgba(color, alpha) {
if (!color || typeof color !== "string") {
return null;
}
const input = color.trim();
const clampAlpha = (value) => Math.max(0, Math.min(1, value));
const safeAlpha = Number.isFinite(alpha) ? clampAlpha(alpha) : 1;
if (input.startsWith("#")) {
const hex = input.slice(1);
if (hex.length === 3) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
}
if (hex.length === 6 || hex.length === 8) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
}
return null;
}
if (input.startsWith("rgb")) {
const match = input.match(/rgba?\(([^)]+)\)/i);
if (!match) {
return null;
}
const parts = match[1].split(",").map((part) => part.trim());
if (parts.length < 3) {
return null;
}
const r = Number(parts[0]);
const g = Number(parts[1]);
const b = Number(parts[2]);
if (![r, g, b].every((v) => Number.isFinite(v))) {
return null;
}
return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
}
return null;
}
// Smoked-glass style tuning constants (per-theme).
// Light theme: make the canvas more opaque and darker (mid neutral) to preserve contrast.
const GLASS_ALPHA = { light: 0.94, dark: 0.65 };
const GLASS_BORDER_ALPHA = { light: 0.42, dark: 0.4 };
const GLASS_SHADOW_ALPHA = { light: 0.75, dark: 0.75 };
const GLASS_SHADOW = {
light: { x: 0, y: 1, blur: 1 },
dark: { x: 0, y: 1, blur: 1 },
};
const GLASS_GRID_ALPHA = { light: 0.16, dark: 0.14 };
// Neutral canvas base to avoid "white wash" in light theme (esp. small previews).
const CANVAS_NEUTRAL_BASE = { light: "#9DA3AD" };
// Resolve the shared canvas surface/border/shadow/grid colors from the active theme.
export function getSmokedGlassCanvasStyle(theme, fallback = {}) {
const isLight =
theme?.isLight ??
fallback.isLight ??
(typeof document !== "undefined"
? document.documentElement?.classList?.contains("comfy-theme-light")
: false) ??
false;
const base =
theme?.inputBg ||
theme?.menuBgSecondary ||
theme?.menuBg ||
theme?.background ||
fallback.inputBg ||
fallback.menuBgSecondary ||
fallback.menuBg ||
fallback.background;
const borderBase =
theme?.border || theme?.text || fallback.border || fallback.text;
const textBase =
theme?.text || theme?.inputText || fallback.text || fallback.inputText;
const surfaceBase = isLight ? CANVAS_NEUTRAL_BASE.light : base;
const surface =
toRgba(surfaceBase, isLight ? GLASS_ALPHA.light : GLASS_ALPHA.dark) ||
surfaceBase;
const border = "rgba(255,255,255,0.3)";
const shadowColor =
toRgba(
"#000000",
isLight ? GLASS_SHADOW_ALPHA.light : GLASS_SHADOW_ALPHA.dark,
) || "#000000";
const shadowSpec = isLight ? GLASS_SHADOW.light : GLASS_SHADOW.dark;
const shadow = `${shadowSpec.x}px ${shadowSpec.y}px ${shadowSpec.blur}px ${shadowColor}`;
const gridBase = isLight ? "#000000" : (borderBase || textBase);
const gridAlpha = isLight ? GLASS_GRID_ALPHA.light : GLASS_GRID_ALPHA.dark;
const gridColor =
toRgba(gridBase, gridAlpha) || toRgba(textBase, gridAlpha) || gridBase;
return {
isLight,
surface,
border,
shadow,
shadowColor,
shadowSpec,
gridColor,
};
}
// ============================================================================
// Global Alert Styles
// ============================================================================
let globalAlertStylesInjected = false;
/**
* Injects universal alert CSS styles into the document.
* Creates a unified alert component system for warnings, info, etc.
* Should be called once when the module loads.
*/
export function injectGlobalAlertStyles() {
if (globalAlertStylesInjected) {
return;
}
globalAlertStylesInjected = true;
}
// ============================================================================
// Support link (shared by Pose Editor and Pose Merger sidebars)
// ============================================================================
/**
* Returns the support link shown in editor sidebars.
* Drop this inside any sidebar card; call applyDonationFooterStyles() after.
*/
export function buildDonationFooterHtml() {
return `
<div class="openpose-donation-footer">
<button class="openpose-donation-footer-text" type="button" data-action="open-about"><span class="openpose-donation-heart" aria-hidden="true">💙</span> ${t("pose_editor.support.text")}</button>
</div>`;
}
/**
* Apply styles and wire every support link inside `container`.
* found inside `container`. Safe to call repeatedly (guards via dataset flags).
*/
export function applyDonationFooterStyles(container, onOpenAbout) {
if (!container) return;
container.querySelectorAll(".openpose-donation-footer").forEach((footer) => {
footer.classList.add("openpose-donation-footer-styled");
const link = footer.querySelector('[data-action="open-about"]');
if (link && !link.dataset.clickReady && typeof onOpenAbout === "function") {
link.dataset.clickReady = "1";
link.addEventListener("click", onOpenAbout);
}
});
}