scripts/ghostyles-manager.jsjavascript
/** @module ghostyles-manager */
import { state } from './state.js';
import { setLog, formatRelativeTime, asErrorLabel } from './utils.js';
import { els, clearActiveEffect, effectSelected } from './dom.js';
import { t } from './i18n.js';
/**
* Retrieves ghostyle metadata from a remote script URL.
* Parses the script text to extract the `@name` comment and derives an identifier
* from the filename. Returns an object containing `id`, `name` and the original `url`.
*
* window.gstmxx is the local API used by the ghostyle(s)
* In regards of the loading, you can see from the network manager that each JS is loaded twice.
* The first time is to extract the metadata, the second time is to import the module.
* @param {string} url - The URL of the ghostyle script to fetch.
* @throws {Error} Throws an error if the HTTP request fails or the response is not OK.
* @see scripts/main.js – loads ghostyle list and calls this function for each URL.
* @see tests/unit/ghostyles-manager.test.js – unit tests for metadata extraction.
* @returns {{
* id: string,
* name: string,
* url: string,
* version: (string|null),
* author: (string|null),
* description: (string|null),
* releaseDate: (string|null)
* }} Metadata for the ghostyle.
*/
export async function fetchGhostyleMetadata(url) {
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const matchName = text.match(/@name\s+(.+)/);
const matchVersion = text.match(/@version\s+([^\n\r*]+)/i);
const matchAuthor = text.match(/@author\s+([^\n\r*]+)/i);
const matchDescription = text.match(/@description\s+([^\n\r*]+)/i);
const matchReleaseDate = text.match(/@release_date\s+([^\n\r*]+)/i);
const filename = (url.split('/').pop() || '').split('?')[0];
const id = filename.replace(/\.js$/i, '');
const name = matchName ? matchName[1].trim() : id;
const version = matchVersion ? matchVersion[1].trim() : null;
const author = matchAuthor ? matchAuthor[1].trim() : null;
const description = matchDescription ? matchDescription[1].trim() : null;
const releaseDate = matchReleaseDate ? matchReleaseDate[1].trim() : null;
return {
id,
name,
url,
version,
author,
description,
releaseDate,
hasName: Boolean(matchName),
hasVersion: Boolean(matchVersion),
hasReleaseDate: Boolean(matchReleaseDate)
};
}
/**
* Dynamically imports a ghostyle module after its metadata has been retrieved.
* Returns an object containing the original metadata plus the loaded module.
*
* @param {{id:string, name:string, url:string, version:(string|null), author:(string|null), description:(string|null), releaseDate:(string|null)}} param - The ghostyle metadata.
* @returns {{id:string, name:string, module:any, url:string, version:(string|null), author:(string|null), description:(string|null), releaseDate:(string|null)}} The ghostyle with its imported module.
* @see main.js – after metadata extraction, this function loads the actual ghostyle code.
* @see tests/unit/ghostyles-manager.test.js – tests import behavior.
*/
export async function importGhostyleModule({ id, name, url, version = null, author = null, description = null, releaseDate = null }) {
const module = await import(url);
return { id, name, module, url, version, author, description, releaseDate };
}
/**
* Unified plugin loader for both engines.
*
* Every ghostyle is loaded from the same manifest. Runtime capabilities are
* detected from module exports:
* - if module exports `onDraw`, it can render on face-api ticks
* - if module exports `paintUV`, it can render on MediaPipe UV ticks
*
* @param {string} url
* @param {string} expectedName
* @param {{onFaceapiToggle: ?Function}} options
* @returns {Promise<object|null>}
*/
export async function loadGhostyle(url, expectedName, options = {}) {
let metadata;
try {
metadata = await fetchGhostyleMetadata(url);
} catch (err) {
throw new Error(`Errore metadata plugin (${expectedName || url}): ${err.message}`);
}
let ghostyle = null;
try {
ghostyle = await importGhostyleModule(metadata);
} catch (err) {
throw new Error(`Errore durante l'importazione del modulo: ${err.message}`);
}
if (!metadata.hasName) {
setLog(t('plugin_missing_name_log', { id: metadata.id, fallback: metadata.id }), 'loader');
ghostyle.name = metadata.id;
} else {
ghostyle.name = metadata.name;
}
if (!metadata.hasVersion) {
setLog(t('plugin_missing_version_log', { id: metadata.id }), 'loader');
}
if (!hasRenderableCallback(ghostyle.module)) {
setLog(t('plugin_not_renderable_log', { id: metadata.id }), 'loader');
return null;
}
if (metadata.hasReleaseDate && !isValidDate(metadata.releaseDate)) {
setLog(t('plugin_invalid_release_date_log', { id: metadata.id, releaseDate: metadata.releaseDate }), 'loader');
ghostyle.releaseDate = null;
}
ghostyle.freshnessLabel = await resolveFreshnessLabel(ghostyle);
ghostyle.module = wrapPluginCallbacks(ghostyle);
if (ghostyle.module.onInit) {
try {
const message = ghostyle.module.onInit();
if (message) {
setLog(`${ghostyle.name}: ${message}`);
}
} catch (err) {
reportPluginRuntimeError(ghostyle.id, err, 'onInit');
}
}
const btn = addGhostyleBtn(ghostyle);
btn.onclick = () => {
toggleEffect(ghostyle.id, btn);
if (typeof options.onFaceapiToggle === 'function') {
options.onFaceapiToggle();
}
};
setLog(t('ghostyle_loaded_log', { name: ghostyle.name, url }));
return ghostyle;
}
function hasRenderableCallback(module) {
const onDraw = module && 'onDraw' in module ? module.onDraw : undefined;
const paintUV = module && 'paintUV' in module ? module.paintUV : undefined;
return Boolean(
module &&
(typeof onDraw === 'function' || typeof paintUV === 'function')
);
}
function isValidDate(dateLike) {
if (!dateLike) return false;
const parsed = new Date(dateLike);
return !Number.isNaN(parsed.getTime());
}
function reportPluginRuntimeError(pluginId, err, hookName) {
const message = asErrorLabel(err);
setLog(t('plugin_runtime_error_log', { id: pluginId, message, hook: hookName }), pluginId);
console.error(`[plugin:${pluginId}] errore in ${hookName}:`, err);
deactivatePluginOnError(pluginId);
}
function deactivatePluginOnError(pluginId) {
const btn = document.querySelector(`.preview-btn[data-effect="${pluginId}"]`);
if (btn) btn.classList.remove('active');
if (state.activeEffect === pluginId) {
clearActiveEffect();
messageEffectChange(null, pluginId);
}
}
function wrapPluginCallbacks(ghostyle) {
const module = ghostyle.module || {};
const wrappedModule = { ...module };
const originalOnClear = 'onClear' in module ? module.onClear : undefined;
const originalOnDraw = 'onDraw' in module ? module.onDraw : undefined;
const originalPaintUV = 'paintUV' in module ? module.paintUV : undefined;
if (typeof originalOnClear === 'function') {
wrappedModule.onClear = (ctx) => {
try {
return originalOnClear(ctx);
} catch (err) {
reportPluginRuntimeError(ghostyle.id, err, 'onClear');
return undefined;
}
};
}
if (typeof originalOnDraw === 'function') {
wrappedModule.onDraw = (ctx, landmarks, box) => {
try {
return originalOnDraw(ctx, landmarks, box);
} catch (err) {
reportPluginRuntimeError(ghostyle.id, err, 'onDraw');
return undefined;
}
};
}
if (typeof originalPaintUV === 'function') {
wrappedModule.paintUV = (ctx, params, helpers) => {
try {
return originalPaintUV(ctx, params, helpers);
} catch (err) {
reportPluginRuntimeError(ghostyle.id, err, 'paintUV');
return undefined;
}
};
}
return wrappedModule;
}
async function getLastModifiedLabel(url) {
try {
const headRes = await fetch(url, { method: 'HEAD', cache: 'no-store' });
if (!headRes.ok) return null;
const lastModified = headRes.headers.get('Last-Modified');
if (!isValidDate(lastModified)) return null;
return formatRelativeTime(lastModified);
} catch {
return null;
}
}
async function resolveFreshnessLabel(record) {
if (record.releaseDate && isValidDate(record.releaseDate)) {
return formatRelativeTime(record.releaseDate);
}
const fromHead = await getLastModifiedLabel(record.url);
return fromHead || 'n/d';
}
function addGhostyleBtn(record) {
state.loadedGhostyles.set(record.id, record);
const btn = document.createElement('button');
btn.className = 'preview-btn';
btn.setAttribute('aria-label', record.name);
const title = document.createElement('span');
title.className = 'preview-btn__title';
title.textContent = record.name;
btn.appendChild(title);
const meta = document.createElement('span');
meta.className = 'preview-btn__meta';
meta.textContent = t('updated_meta_label', { time: record.freshnessLabel || 'n/d' });
if (record.releaseDate) meta.title = record.releaseDate;
btn.appendChild(meta);
btn.dataset.effect = record.id;
els.ghostylesContainer.appendChild(btn);
const row = document.createElement('div');
row.className = 'ghostyle-row';
row.dataset.effect = record.id;
if (btn.parentNode) {
btn.parentNode.insertBefore(row, btn);
}
row.appendChild(btn);
const pinsDiv = document.createElement('div');
pinsDiv.className = 'ghostyle-pins';
const pin1 = document.createElement('button');
pin1.className = 'pin-btn pin-btn--1';
pin1.title = t('pin_to_slot_title', { slot: 1 });
pin1.setAttribute('aria-label', t('pin_ghostyle_to_slot_label', { name: record.name, slot: 1 }));
pin1.innerHTML = '1';
pinsDiv.appendChild(pin1);
const pin2 = document.createElement('button');
pin2.className = 'pin-btn pin-btn--2';
pin2.title = t('pin_to_slot_title', { slot: 2 });
pin2.setAttribute('aria-label', t('pin_ghostyle_to_slot_label', { name: record.name, slot: 2 }));
pin2.innerHTML = '2';
pinsDiv.appendChild(pin2);
row.appendChild(pinsDiv);
return btn;
}
/* if there is onClear function, it will be called */
/**
* Deactivates the currently active ghostyle effect.
* If the loaded ghostyle module defines an `onClear` hook, it is invoked with the overlay canvas context to allow the effect to clean up any custom drawing state.
* Returns the style object for the previously active effect.
*
* @returns {any} The style object of the previously active ghostyle, if any.
* @see toggleEffect – called when switching effects to ensure the previous effect is properly cleared.
* @see main.js – UI interactions trigger effect toggling which eventually calls this function.
*/
function deactivateEffect() {
const style = state.loadedGhostyles.get(state.activeEffect);
if (style && style.module.onClear) {
try {
style.module.onClear(els.overlay.getContext('2d'));
} catch (err) {
reportPluginRuntimeError(style.id || state.activeEffect, err, 'onClear');
}
}
return style;
}
/**
* Dispatches a custom `effectChanged` event to inform the rest of the application about a change in the active ghostyle effect.
* The event payload includes the new `activeEffect` identifier and the `previous` effect identifier.
*
* @param {string|null} effect - The identifier of the newly active effect, or null if none.
* @param {string|null} previousEffect - The identifier of the effect that was previously active.
* @see toggleEffect – triggers this notification when an effect is switched.
* @see main.js – listens for this event to update UI state.
*/
function messageEffectChange(effect, previousEffect) {
state.gstmxxEvents.dispatchEvent(new CustomEvent('effectChanged', {
detail: { activeEffect: effect, previous: previousEffect }
}));
}
/**
* Toggles a ghostyle effect on or off.
* Handles activation, deactivation, state updates, logging, and UI selection.
*
* @param {string} effect - Identifier of the ghostyle effect.
* @param {HTMLElement} button - The UI button tied to the effect.
* @see deactivateEffect – clears previous effect.
* @see messageEffectChange – notifies of effect changes.
* @see main.js – UI triggers this toggle.
*/
export function toggleEffect(effect, button) {
if (state.activeEffect === effect) {
setLog(t('ghostyle_already_active_log', { name: state.activeEffect }));
deactivateEffect();
clearActiveEffect();
messageEffectChange(null, effect);
return;
}
let previousEffect = null;
if (state.activeEffect) {
previousEffect = state.activeEffect;
deactivateEffect();
}
state.activeEffect = effect;
const ghstyle = state.loadedGhostyles.get(effect);
messageEffectChange(effect, previousEffect);
if (previousEffect) {
setLog(t('ghostyle_switched_log', { previous: previousEffect, name: ghstyle.name }));
} else {
setLog(t('ghostyle_activated_log', { name: ghstyle.name }));
}
effectSelected(button);
}
function withCacheBust(url, token) {
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}t=${token}`;
}
/**
* Reload all plugins from the unified manifest with cache busting.
* The currently active plugin is deactivated and must be reselected.
*
* @param {{manifestUrl: ?string, baseUrl: ?string, onFaceapiToggle: ?Function}} [options]
* @returns {Promise<number>} Number of plugins loaded.
*/
export async function reloadPlugins(options = {}) {
const relurl = options.baseUrl || window.location.pathname.split('/').slice(0, -1).join('/');
const manifestUrl = options.manifestUrl || `${relurl}/ghostyles.json`;
const bust = Date.now();
if (state.activeEffect) {
const previous = state.activeEffect;
deactivateEffect();
clearActiveEffect();
messageEffectChange(null, previous);
}
state.loadedGhostyles = new Map();
if (els.ghostylesContainer) els.ghostylesContainer.innerHTML = '';
const manifestRes = await fetch(withCacheBust(manifestUrl, bust), { cache: 'no-store' });
if (!manifestRes.ok) throw new Error(`HTTP ${manifestRes.status}`);
const list = await manifestRes.json();
let loaded = 0;
for (const item of list) {
const pluginUrl = withCacheBust(`${relurl}/${item.url}`, bust);
const loadedPlugin = await loadGhostyle(pluginUrl, item.id || item.name, {
onFaceapiToggle: options.onFaceapiToggle
});
if (loadedPlugin) loaded += 1;
}
return loaded;
}