scripts/plugins3d-loader.jsjavascript
/**
* @module plugins3d-loader
* @description
* Consumer-only runtime for UV rendering.
*
* Plugin loading and UI button registration are handled by ghostyles-manager.
* This module only listens to app events and, when the currently active plugin
* exports `paintUV`, renders it on the 3D mesh overlay.
*/
import { state } from './state.js';
import { setLog } from './utils.js';
import { createUvRenderer } from './ghostyle3d-uv-renderer.js';
import { clearActiveEffect } from './dom.js';
import { asErrorLabel, syncMirror, syncSize } from './utils.js';
import { t } from './i18n.js';
const runtime = {
initialized: false,
canvas: null,
overlayEl: null,
panel: null,
video: null,
ctx: null,
events: null,
renderer: null,
activePluginId: null,
paramValues: new Map()
};
/**
* Writes loader messages to the shared Ghostmaxxing log under the 3D plugin
* source, so unavailable runtime operations are attributed to this module.
*
* @param {string} message - Message to show in the application log.
* @returns {void}
* @see reloadPlugins3d - Reports that 3D plugin reloading is delegated to `ghostyles-manager`.
*/
function log3d(message) {
setLog(message, 'plugins3d');
}
/**
* Removes a 3D ghostyle from the active UI path after its `paintUV` renderer
* throws, then broadcasts the same effect-change events used by normal plugin
* toggles. The user-facing Italian log message means the plugin threw during
* `paintUV`, and this handler prevents that plugin from breaking subsequent
* mesh frames.
*
* @param {object|null|undefined} entry - Loaded ghostyle entry that failed.
* @param {*} err - Error or thrown value raised by the plugin renderer.
* @returns {void}
* @see initPlugins3dLoader - Called by the `landmarks3d` event listener when UV rendering fails.
*/
function deactivateBroken3dPlugin(entry, err) {
const pluginId = entry?.id || runtime.activePluginId;
if (!pluginId) return;
setLog(t('plugin_runtime_error_log', { id: pluginId, message: asErrorLabel(err), hook: 'paintUV' }), pluginId);
console.error(`[plugins3d] paintUV errore in ${entry?.name || pluginId}:`, err);
const btn = document.querySelector(`[data-effect="${pluginId}"]`);
if (btn) btn.classList.remove('active');
if (state.activeEffect === pluginId) {
const previous = pluginId;
clearActiveEffect();
runtime.activePluginId = null;
runtime.events.dispatchEvent(new CustomEvent('effectChanged', {
detail: { activeEffect: null, previous }
}));
runtime.events.dispatchEvent(new CustomEvent('effectChanged3d', {
detail: { active: null, previous }
}));
}
}
/**
* Guards public 3D control APIs so callers cannot manipulate plugin buttons
* before the loader has captured its DOM nodes and event bus. The thrown
* Italian message means `initPlugins3dLoader()` has not been called yet.
*
* @returns {void}
* @throws {Error} When the 3D loader runtime has not been initialized.
* @see activateEffect3d - Verifies initialization before activating a 3D effect.
* @see deactivateEffect3d - Verifies initialization before deactivating a 3D effect.
* @see toggleEffect3d - Verifies initialization before toggling a 3D effect.
* @see reloadPlugins3d - Verifies initialization before reporting reload availability.
*/
function requireInit() {
if (!runtime.initialized) {
throw new Error(t('plugins3d_not_initialized_error'));
}
}
/**
* Clears the 3D plugin canvas between mesh frames or when no UV-capable plugin
* remains active, avoiding stale painted makeup on the overlay.
*
* @returns {void}
* @see syncActiveEntry - Clears the overlay when the active effect has no `paintUV` renderer.
* @see initPlugins3dLoader - Clears the overlay before drawing each `landmarks3d` frame.
*/
function clearCanvas() {
runtime.ctx.clearRect(0, 0, runtime.canvas.width, runtime.canvas.height);
}
/**
* Normalizes a 3D ghostyle parameter value to the supported project schema
* (`range`, `bool`, `select`, or RGB `color`) before it is stored for `paintUV`.
* This keeps panel input values, plugin defaults, and renderer parameters in
* the same shape regardless of browser form serialization.
*
* @param {object} p - Plugin parameter definition.
* @param {string} p.type - Supported parameter control type.
* @param {*} p.default - Default value declared by the ghostyle.
* @param {number} [p.step] - Numeric slider increment.
* @param {number} [p.min] - Numeric slider minimum.
* @param {number} [p.max] - Numeric slider maximum.
* @param {Array<*>} [p.options] - Selectable values for `select` parameters.
* @param {*} value - Raw value from the plugin manifest or parameter control.
* @returns {*} Coerced value ready to pass to the active ghostyle.
* @see ensureParamValues - Coerces initial plugin defaults.
* @see createParamRow - Coerces values read from parameter panel controls.
*/
function coerceParam(p, value) {
if (p.type === 'range') {
let v = (typeof value === 'number') ? value : parseFloat(value);
if (!Number.isFinite(v)) v = p.default;
const step = (typeof p.step === 'number') ? p.step : 0.01;
if (Number.isInteger(step) && step >= 1) v = Math.round(v);
if (typeof p.min === 'number') v = Math.max(p.min, v);
if (typeof p.max === 'number') v = Math.min(p.max, v);
return v;
}
if (p.type === 'bool') return Boolean(value);
if (p.type === 'select') {
const opts = Array.isArray(p.options) ? p.options : [];
return opts.includes(value) ? value : p.default;
}
if (p.type === 'color') {
if (Array.isArray(value) && value.length === 3) {
return value.map((c) => {
const n = Math.round(Number(c));
return Math.max(0, Math.min(255, Number.isFinite(n) ? n : 0));
});
}
if (typeof value === 'string') {
const short = /^#([0-9a-fA-F]{3})$/.exec(value);
if (short) {
const c = short[1];
return [
parseInt(c[0] + c[0], 16),
parseInt(c[1] + c[1], 16),
parseInt(c[2] + c[2], 16)
];
}
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
return [
parseInt(value.slice(1, 3), 16),
parseInt(value.slice(3, 5), 16),
parseInt(value.slice(5, 7), 16)
];
}
}
return value === p.default ? [0, 0, 0] : coerceParam(p, p.default);
}
return value;
}
/**
* Converts a stored RGB array from the 3D parameter map into a browser color
* input value, clamping invalid plugin-provided components to black-safe
* channel values.
*
* @param {number[]} rgb - RGB triplet stored for a color parameter.
* @returns {string} Hex color string used by the parameter panel.
* @see createParamRow - Initializes and updates color controls for 3D ghostyle parameters.
*/
function rgbToHex(rgb) {
if (!Array.isArray(rgb) || rgb.length < 3) return '#000000';
return '#' + rgb.slice(0, 3).map((c) => {
const n = Math.max(0, Math.min(255, Math.round(Number(c) || 0)));
return n.toString(16).padStart(2, '0');
}).join('');
}
/**
* Creates the per-plugin parameter state used by `paintUV`, seeded from the
* ghostyle module defaults only once per plugin so user adjustments survive
* ordinary active-effect refreshes.
*
* @param {string} id - Loaded ghostyle identifier.
* @param {object} module - Ghostyle module that may expose configurable UV parameters.
* @param {Array<object>} [module.params] - Parameter definitions declared by the ghostyle.
* @returns {void}
* @see syncActiveEntry - Ensures parameters exist whenever a UV-capable ghostyle becomes active.
*/
function ensureParamValues(id, module) {
if (!Array.isArray(module.params) || module.params.length === 0) {
runtime.paramValues.delete(id);
return;
}
if (runtime.paramValues.has(id)) return;
const values = {};
for (const p of module.params) values[p.name] = coerceParam(p, p.default);
runtime.paramValues.set(id, values);
}
/**
* Publishes the parameter panel height into the `--pp-h` CSS variable so the
* rest of the lab UI can reserve space when 3D ghostyle controls are visible.
*
* @returns {void}
* @see hideParamsPanel - Resets the CSS variable when controls are hidden.
* @see renderParamsPanel - Updates the CSS variable after rendering controls.
*/
function syncPanelHeightVar() {
const h = runtime.panel.classList.contains('visible') ? runtime.panel.offsetHeight + 12 : 0;
document.documentElement.style.setProperty('--pp-h', h + 'px');
}
/**
* Builds one row in the floating 3D parameter panel for a ghostyle manifest
* parameter, wiring browser controls back into the runtime value map that is
* later passed to `paintUV`.
*
* @param {string} pluginId - Loaded ghostyle identifier whose values should be edited.
* @param {object} p - Parameter definition from the ghostyle module.
* @param {string} p.name - Runtime parameter key passed to `paintUV`.
* @param {string} [p.label] - Visible label used in the panel.
* @param {string} p.type - Supported parameter control type.
* @param {number} [p.min] - Numeric slider minimum.
* @param {number} [p.max] - Numeric slider maximum.
* @param {number} [p.step] - Numeric slider increment.
* @param {Array<*>} [p.options] - Selectable values for `select` parameters.
* @returns {HTMLDivElement|null} Parameter row, or null when the type cannot be rendered.
* @see renderParamsPanel - Creates rows for the active 3D ghostyle parameter list.
*/
function createParamRow(pluginId, p) {
const values = runtime.paramValues.get(pluginId);
if (!values) return null;
const row = document.createElement('div');
row.className = 'pp-row';
const label = document.createElement('label');
label.className = 'pp-label';
label.textContent = p.label || p.name;
row.appendChild(label);
const ctrlWrap = document.createElement('div');
ctrlWrap.className = 'pp-control';
if (p.type === 'range') {
const input = document.createElement('input');
input.type = 'range';
input.min = String(p.min);
input.max = String(p.max);
input.step = String(p.step || 0.01);
input.value = String(values[p.name]);
const valueLabel = document.createElement('span');
valueLabel.className = 'pp-value';
const fmt = (v) => (Number(p.step) >= 1 ? String(v) : Number(v).toFixed(2));
valueLabel.textContent = fmt(values[p.name]);
input.addEventListener('input', () => {
const v = coerceParam(p, input.value);
values[p.name] = v;
valueLabel.textContent = fmt(v);
});
ctrlWrap.appendChild(input);
row.appendChild(ctrlWrap);
row.appendChild(valueLabel);
return row;
}
if (p.type === 'bool') {
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(values[p.name]);
input.addEventListener('input', () => {
values[p.name] = coerceParam(p, input.checked);
});
ctrlWrap.appendChild(input);
row.appendChild(ctrlWrap);
return row;
}
if (p.type === 'select') {
const select = document.createElement('select');
for (const opt of (p.options || [])) {
const o = document.createElement('option');
o.value = String(opt);
o.textContent = String(opt);
if (opt === values[p.name]) o.selected = true;
select.appendChild(o);
}
select.addEventListener('input', () => {
values[p.name] = coerceParam(p, select.value);
});
ctrlWrap.appendChild(select);
row.appendChild(ctrlWrap);
return row;
}
if (p.type === 'color') {
const input = document.createElement('input');
input.type = 'color';
input.value = rgbToHex(values[p.name]);
const valueLabel = document.createElement('span');
valueLabel.className = 'pp-value';
valueLabel.textContent = rgbToHex(values[p.name]);
input.addEventListener('input', () => {
values[p.name] = coerceParam(p, input.value);
valueLabel.textContent = rgbToHex(values[p.name]);
});
ctrlWrap.appendChild(input);
row.appendChild(ctrlWrap);
row.appendChild(valueLabel);
return row;
}
return null;
}
/**
* Hides and empties the 3D parameter panel when no UV-capable ghostyle is
* active, or when the active ghostyle has no configurable parameters.
*
* @returns {void}
* @see renderParamsPanel - Delegates here for plugins without parameters.
* @see syncActiveEntry - Hides controls when the active effect is not a 3D renderer.
*/
function hideParamsPanel() {
runtime.panel.classList.remove('visible');
runtime.panel.setAttribute('aria-hidden', 'true');
runtime.panel.innerHTML = '';
syncPanelHeightVar();
}
/**
* Renders the floating parameter panel for the active UV ghostyle. The existing
* Italian heading "Parametri" is the visible label for plugin controls and is
* generated here together with the active plugin name.
*
* @param {object|null|undefined} entry - Active loaded ghostyle entry.
* @returns {void}
* @see syncActiveEntry - Refreshes the panel whenever the active effect changes.
*/
function renderParamsPanel(entry) {
runtime.panel.innerHTML = '';
if (!entry || !Array.isArray(entry.module.params) || entry.module.params.length === 0) {
hideParamsPanel();
return;
}
const title = document.createElement('div');
title.className = 'pp-header';
title.textContent = t('params_heading', { name: entry.name });
runtime.panel.appendChild(title);
for (const p of entry.module.params) {
const row = createParamRow(entry.id, p);
if (row) runtime.panel.appendChild(row);
}
runtime.panel.classList.add('visible');
runtime.panel.classList.remove('collapsed');
runtime.panel.setAttribute('aria-hidden', 'false');
requestAnimationFrame(syncPanelHeightVar);
}
/**
* Resolves the current global active effect to a loaded ghostyle entry only
* when that module exports the `paintUV` hook required by the 3D mesh overlay.
*
* @returns {object|null} Active UV-capable ghostyle entry.
* @see syncActiveEntry - Uses this lookup to align runtime state with `state.activeEffect`.
*/
function getActivePaintEntry() {
const activeId = state.activeEffect;
if (!activeId) return null;
const entry = state.loadedGhostyles.get(activeId);
if (!entry || !entry.module || typeof entry.module.paintUV !== 'function') return null;
return entry;
}
/**
* Synchronizes loader state after a ghostyle effect change by selecting the
* active `paintUV` entry, preparing its parameters, refreshing controls, and
* notifying listeners through `effectChanged3d`.
*
* @returns {void}
* @see initPlugins3dLoader - Called during initialization and by the `effectChanged` event listener.
*/
function syncActiveEntry() {
const prev = runtime.activePluginId;
const entry = getActivePaintEntry();
runtime.activePluginId = entry ? entry.id : null;
if (entry) {
ensureParamValues(entry.id, entry.module);
renderParamsPanel(entry);
} else {
hideParamsPanel();
clearCanvas();
}
if (prev !== runtime.activePluginId) {
runtime.events.dispatchEvent(new CustomEvent('effectChanged3d', {
detail: { active: runtime.activePluginId, previous: prev }
}));
}
}
/**
* Returns the active 3D ghostyle id tracked by this loader, or null when the
* current active effect is not a UV-capable plugin.
*
* @returns {string|null} Active 3D plugin id.
* @see main - Exposed as `window.gstmxx.getActiveEffect3d`.
* @see hasActivePlugin - Checks whether 2D or 3D compositing should run.
* @see hasActivePlugin3d - Checks active plugin state before 3D efficacy compositing.
*/
export function getActiveEffect3d() {
return runtime.activePluginId;
}
/**
* Activates a UV-capable ghostyle by delegating to its existing effect button,
* keeping 3D activation on the same UI path as regular ghostyle toggles.
*
* @param {string} id - Loaded ghostyle id to activate.
* @returns {boolean} True when a matching clickable 3D effect button was triggered.
* @see main - Exposed as `window.gstmxx.activateEffect3d`.
* @see toggleEffect3d - Uses this when the requested 3D plugin is not already active.
*/
export function activateEffect3d(id) {
requireInit();
const entry = state.loadedGhostyles.get(id);
if (!entry || typeof entry.module?.paintUV !== 'function') return false;
const btn = document.querySelector(`[data-effect="${id}"]`);
if (!btn || typeof btn.click !== 'function') return false;
btn.click();
return true;
}
/**
* Deactivates the current UV-capable ghostyle by clicking the active effect
* button, preserving the unified active-effect behavior owned by the UI.
*
* @returns {boolean} True when the active 3D effect button was triggered.
* @see main - Exposed as `window.gstmxx.deactivateEffect3d`.
* @see toggleEffect3d - Uses this when the requested 3D plugin is already active.
*/
export function deactivateEffect3d() {
requireInit();
if (!state.activeEffect || state.activeEffect !== runtime.activePluginId) return false;
const btn = document.querySelector(`[data-effect="${state.activeEffect}"]`);
if (!btn || typeof btn.click !== 'function') return false;
btn.click();
return true;
}
/**
* Toggles a UV-capable ghostyle through the shared effect-button workflow,
* activating it when inactive and deactivating it when it is already selected.
*
* @param {string} id - Loaded ghostyle id to toggle.
* @returns {boolean} True when a matching UI button was triggered.
* @see main - Exposed as `window.gstmxx.toggleEffect3d`.
*/
export function toggleEffect3d(id) {
requireInit();
if (runtime.activePluginId === id) return deactivateEffect3d();
return activateEffect3d(id);
}
/**
* Compatibility hook for older 3D plugin callers. In the current project,
* plugin discovery and reloads are handled by `ghostyles-manager`, so this
* function logs that 3D-specific reload is not available and returns false.
*
* @returns {boolean} Always false because 3D plugins are managed by `ghostyles-manager`.
* @see main - Exposed as `window.gstmxx.reloadPlugins3d`.
*/
export function reloadPlugins3d() {
requireInit();
log3d('reloadPlugins3d non disponibile: i plugin sono gestiti da ghostyles-manager.');
return false;
}
/**
* Initializes the 3D plugin runtime by binding the overlay canvas, parameter
* panel, app event bus, and UV renderer. It then listens for active-effect,
* compositing, and MediaPipe landmark events so loaded ghostyles with `paintUV`
* can draw onto the mesh overlay without owning plugin loading themselves.
*
* @param {object} [options={}] - DOM id and renderer overrides used by startup and tests.
* @param {string} [options.canvasId='mesh3dOverlay'] - Canvas receiving rendered UV makeup.
* @param {string} [options.overlayId='overlay'] - Main overlay whose size and mirror transform are followed.
* @param {string} [options.panelId='plugin3dParamsPanel'] - Floating panel for active plugin parameters.
* @param {string} [options.videoId='video'] - Webcam video element required by the runtime.
* @param {string} [options.baseUrl] - Base URL for resolving the canonical UV data file.
* @param {string} [options.uvPath] - Explicit URL for `face_canonical_uv.json`.
* @param {Function} [options.getFaceLandmarker] - Provider for the MediaPipe FaceLandmarker instance.
* @returns {object} Initialized singleton runtime.
* @throws {Error} When the required DOM elements are missing.
* @see main - Called during application startup before `ghostyles-manager` loads unified ghostyles.
*/
export function initPlugins3dLoader(options = {}) {
if (runtime.initialized) return runtime;
runtime.canvas = document.getElementById(options.canvasId || 'mesh3dOverlay');
runtime.overlayEl = document.getElementById(options.overlayId || 'overlay');
runtime.panel = document.getElementById(options.panelId || 'plugin3dParamsPanel');
runtime.video = document.getElementById(options.videoId || 'video');
if (!runtime.canvas || !runtime.overlayEl || !runtime.panel || !runtime.video) {
throw new Error(t('plugins3d_missing_dom_error'));
}
runtime.ctx = runtime.canvas.getContext('2d');
runtime.events = state.gstmxxEvents;
const relurl = options.baseUrl || window.location.pathname.split('/').slice(0, -1).join('/');
const uvPath = options.uvPath || (relurl + '/data/face_canonical_uv.json');
runtime.renderer = createUvRenderer({
uvPath,
getFaceLandmarker: options.getFaceLandmarker || (() => (window.gstmxx && window.gstmxx.FaceLandmarker) || null),
log: (message) => setLog(message, 'uv-renderer')
});
runtime.renderer.ensureLoaded();
runtime.events.addEventListener('effectChanged', () => {
syncActiveEntry();
});
runtime.events.addEventListener('beforeEfficacyComposite', (e) => {
const detail = e.detail || {};
if (!runtime.activePluginId || !detail.canvas || !detail.ctx) return;
detail.ctx.drawImage(runtime.canvas, 0, 0, detail.canvas.width, detail.canvas.height);
});
runtime.events.addEventListener('beforeEfficacyComposite3d', (e) => {
const detail = e.detail || {};
if (!runtime.activePluginId || !detail.canvas || !detail.ctx) return;
detail.ctx.drawImage(runtime.canvas, 0, 0, detail.canvas.width, detail.canvas.height);
});
runtime.events.addEventListener('landmarks3d', (e) => {
const landmarks = e.detail && e.detail.landmarks;
syncSize(runtime.canvas, runtime.overlayEl);
syncMirror(runtime.canvas, runtime.overlayEl);
clearCanvas();
if (!runtime.activePluginId || !landmarks) return;
const entry = state.loadedGhostyles.get(runtime.activePluginId);
if (!entry || typeof entry.module?.paintUV !== 'function') return;
try {
runtime.ctx.save();
runtime.renderer.render(entry.module, runtime.ctx, landmarks, runtime.paramValues.get(runtime.activePluginId) || {});
runtime.ctx.restore();
} catch (err) {
deactivateBroken3dPlugin(entry, err);
}
});
runtime.initialized = true;
syncActiveEntry();
return runtime;
}