scripts/export-makeup.jsjavascript
import { state } from './state.js';
import { els } from './dom.js';
import { setLog } from './utils.js';
import { t } from './i18n.js';
const EXPORT_LAYOUT = {
headerHeight: 44,
footerHeight: 50,
backgroundColor: '#0f1115',
defaultTextColor: '#eef2ff',
successTextColor: '#3ddc97',
errorTextColor: '#ff7a7a'
};
const EXPORT_COPY = {
filename: 'ghostati-makeup.png',
mimeType: 'image/png',
shareTitle: 'Ghostmaxxing Makeup',
shareText: 'Learn some adversarial makeup techniques!',
headerPrefix: 'https://github.com/vecna/ghostati',
reportUrl: 'https://vecna.eu/ghostmaxxing//'
};
/**
* Coordinates the Copy Makeup button workflow. It gathers the current
* Ghostmaxxing export source, renders the diagnostic header/footer around it,
* converts the result to PNG, and then tries Clipboard first with Web Share as
* a fallback. This is the only function in the module that reads the live
* application globals and writes user-visible status messages through `setLog`;
* the exported helper functions stay small so the rendering and delivery
* decisions can be unit-tested independently.
*
* @returns {Promise<void>} Resolves after the image is copied, shared, skipped, or an error is logged.
* @see main - Registered as the `copyMakeupBtn` click handler.
* @see collectExportInput - Builds the export model from app state and DOM handles.
* @see renderExportCanvas - Produces the PNG source canvas with report chrome.
* @see copyBlobToClipboard - First delivery path attempted by this workflow.
* @see shareImageFile - Fallback delivery path when clipboard write fails.
*/
export async function exportMakeup() {
const imgInfo = collectExportInput(state, els);
if (!imgInfo.sourceCanvas) return;
try {
const exportCanvas = renderExportCanvas(imgInfo, EXPORT_LAYOUT);
const blob = await canvasToBlob(exportCanvas);
try {
await copyBlobToClipboard(blob);
setLog(t('image_copied_log'));
return;
} catch (err) {
console.error(t('console_clipboard_fallback'), err);
}
const file = makeImageFile(blob, EXPORT_COPY);
try {
await shareImageFile(file, EXPORT_COPY);
setLog(t('image_shared_log'));
} catch (err) {
console.error(t('console_share_failed'), err);
setLog(t('image_copy_unavailable_log'));
}
} catch (err) {
console.error(err);
setLog(t('image_copy_error_log'));
}
}
/**
* Builds the data model for a makeup export from the current app state and DOM.
* It prefers the last efficacy composite when available, otherwise it creates a
* live composite from the webcam and overlay layers so the Copy button still
* returns a useful image when no active Ghostyle result has been produced.
*
* @param {object} appState - Current Ghostmaxxing state object.
* @param {HTMLCanvasElement|null} [appState.lastCompositedCanvas] - Most recent efficacy composite, if one exists.
* @param {boolean} appState.isMirrored - Whether the exported source should be mirrored.
* @param {Map<string, object>} appState.loadedGhostyles - Loaded Ghostyle records keyed by id.
* @param {string|null} appState.activeEffect - Currently active Ghostyle id.
* @param {object} domEls - DOM handles collected by `dom.js`.
* @param {HTMLElement} [domEls.logBox] - Log container whose last child becomes footer text.
* @returns {{sourceCanvas: HTMLCanvasElement|null, isMirrored: boolean, pluginName: string, logText: string}} Export input for rendering.
* @see exportMakeup - Calls this before rendering or delivery.
* @see buildLiveComposite - Provides the fallback source canvas when no efficacy composite exists.
*/
export function collectExportInput(appState, domEls) {
const style = appState.loadedGhostyles.get(appState.activeEffect);
const sourceCanvas = appState.lastCompositedCanvas || buildLiveComposite(domEls);
return {
sourceCanvas,
isMirrored: appState.isMirrored,
pluginName: style ? style.name : 'Nessun Ghostyle',
logText: getLatestLogText(domEls.logBox)
};
}
/**
* Composites the visible lab layers into a single source canvas in overlay
* pixel space: webcam frame, 2D effect overlay, 3D mesh overlay, then bbox/label
* overlay. Mirroring is intentionally left for `drawSourceImage`, matching the
* efficacy export path and preserving readable bbox labels.
*
* @param {object} domEls - DOM handles collected by `dom.js`.
* @param {HTMLCanvasElement} domEls.overlay - Main 2D effect overlay canvas.
* @param {HTMLVideoElement} [domEls.video] - Webcam video element.
* @returns {HTMLCanvasElement|null} Composite canvas, or null when the overlay is unavailable.
* @see collectExportInput - Uses this as the copy source when `lastCompositedCanvas` is empty.
* @see drawSourceImage - Applies the global mirror transform after this raw composite is built.
*/
function buildLiveComposite(domEls) {
const overlay = domEls.overlay;
if (!overlay || !overlay.width || !overlay.height) return null;
const w = overlay.width, h = overlay.height;
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const ctx = c.getContext('2d');
if (!ctx) return null;
const video = domEls.video;
if (video && video.readyState >= 2) {
try { ctx.drawImage(video, 0, 0, w, h); } catch (_) { /* not ready */ }
}
// z-order matches the DOM stacking: #overlay (2D) < #mesh3dOverlay (3D) < #bboxOverlay
const overlays = [overlay, document.getElementById('mesh3dOverlay'), document.getElementById('bboxOverlay')];
for (const layer of overlays) {
if (layer && layer.width && layer.height) {
try { ctx.drawImage(layer, 0, 0, w, h); } catch (_) { /* skip */ }
}
}
return c;
}
/**
* Reads the newest diagnostic line from the app log for use as the export
* footer, preserving the same concise status text the user saw in the lab UI.
*
* @param {HTMLElement} logBox - Log container rendered by the application.
* @returns {string} Text from the latest log entry, or an empty string.
* @see collectExportInput - Stores the value as `logText` for footer rendering.
*/
function getLatestLogText(logBox) {
return logBox.lastChild ? logBox.lastChild.textContent : '';
}
/**
* Allocates and paints the final export image: opaque background, source image,
* diagnostic header, and latest-log footer. The output canvas is taller than
* the source canvas because it includes the report chrome used by shared PNGs.
*
* @param {object} input - Export input collected from app state and DOM.
* @param {HTMLCanvasElement} input.sourceCanvas - Source image to place between header and footer.
* @param {boolean} input.isMirrored - Whether the source image should be horizontally flipped.
* @param {string} input.pluginName - Active Ghostyle name for the header.
* @param {string} input.logText - Latest diagnostic log line for the footer.
* @param {object} layout - Export layout and color settings.
* @returns {HTMLCanvasElement} Rendered export canvas ready for PNG conversion.
* @throws {Error} When a 2D canvas context cannot be created.
* @see exportMakeup - Converts this canvas to a blob for delivery.
* @see drawSourceImage - Draws the captured makeup image.
* @see drawHeader - Draws the Ghostyle/report header.
* @see drawFooter - Draws the diagnostic footer.
*/
function renderExportCanvas(input, layout) {
const canvas = document.createElement('canvas');
canvas.width = input.sourceCanvas.width;
canvas.height = input.sourceCanvas.height + layout.headerHeight + layout.footerHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error(t('canvas_2d_context_error'));
}
ctx.fillStyle = EXPORT_LAYOUT.backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawSourceImage(ctx, { canvas, input, layout });
drawHeader(ctx, { canvas, input, layout });
drawFooter(ctx, { canvas, input, layout });
return canvas;
}
/**
* Places the captured makeup image into the final export canvas, flipping the
* whole source when the webcam is mirrored so the shared PNG matches the user's
* current lab view.
*
* @param {CanvasRenderingContext2D} ctx - Export canvas rendering context.
* @param {object} data - Rendering bundle for the current export.
* @param {HTMLCanvasElement} data.canvas - Final export canvas.
* @param {object} data.input - Export input collected from the app.
* @param {object} data.layout - Header/footer layout settings.
* @returns {void}
* @see renderExportCanvas - Calls this before drawing report text.
*/
function drawSourceImage(ctx, data) {
const { canvas, input, layout } = data;
if (!input.isMirrored) {
ctx.drawImage(input.sourceCanvas, 0, layout.headerHeight);
return;
}
ctx.save();
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(input.sourceCanvas, 0, layout.headerHeight);
ctx.restore();
}
/**
* Draws the top report line containing the project URL, active Ghostyle module,
* and report URL. This makes exported makeup images self-identifying when they
* are copied or shared outside the lab UI.
*
* @param {CanvasRenderingContext2D} ctx - Export canvas rendering context.
* @param {object} data - Rendering bundle for the current export.
* @param {HTMLCanvasElement} data.canvas - Final export canvas.
* @param {object} data.input - Export input collected from the app.
* @param {object} data.layout - Header/footer layout settings.
* @returns {void}
* @see renderExportCanvas - Calls this after the source image is drawn.
* @see buildHeaderText - Builds the actual header string.
*/
function drawHeader(ctx, data) {
const { canvas, input, layout } = data;
ctx.fillStyle = layout.defaultTextColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 14px Inter, sans-serif';
ctx.fillText(
buildHeaderText(input.pluginName),
canvas.width / 2,
layout.headerHeight / 2
);
}
/**
* Builds the export header copy shown above the makeup image, binding the
* active Ghostyle name to the Ghostmaxxing source and report links.
*
* @param {string} pluginName - Human-readable active Ghostyle name.
* @returns {string} Header text for the exported PNG.
* @see drawHeader - Renders this text onto the export canvas.
*/
export function buildHeaderText(pluginName) {
return `${EXPORT_COPY.headerPrefix} | Modulo: ${pluginName} | URL: ${EXPORT_COPY.reportUrl}`;
}
/**
* Draws the latest diagnostic log line below the exported image. The footer
* color reflects whether the last known effect result had a detection, keeping
* successful and failed diagnostic outcomes visually distinct in the shared PNG.
*
* @param {CanvasRenderingContext2D} ctx - Export canvas rendering context.
* @param {object} data - Rendering bundle for the current export.
* @param {HTMLCanvasElement} data.canvas - Final export canvas.
* @param {object} data.input - Export input collected from the app.
* @param {object} data.layout - Header/footer layout settings.
* @returns {void}
* @see renderExportCanvas - Calls this as the final drawing step.
*/
function drawFooter(ctx, data) {
const { canvas, input, layout } = data;
ctx.fillStyle = state.lastKnownEffectResult && state.lastKnownEffectResult.detection ? EXPORT_LAYOUT.successTextColor : EXPORT_LAYOUT.errorTextColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '14px Inter, sans-serif';
ctx.fillText(
input.logText,
canvas.width / 2,
canvas.height - layout.footerHeight / 2
);
}
/**
* Converts the rendered export canvas into the PNG blob delivered to Clipboard
* or Web Share, treating a null browser callback as an export failure.
*
* @param {HTMLCanvasElement} canvas - Final export canvas.
* @returns {Promise<Blob>} PNG blob generated from the canvas.
* @see exportMakeup - Awaits this before clipboard/share delivery.
*/
function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
return;
}
reject(new Error('Canvas export returned an empty blob.'));
}, EXPORT_COPY.mimeType);
});
}
/**
* Wraps the exported PNG blob in a browser `File` with the configured filename
* and MIME type so the Web Share API can receive it.
*
* @param {Blob} blob - PNG blob generated from the export canvas.
* @param {object} copy - Export copy and file metadata.
* @param {string} copy.filename - Filename for the shared image.
* @param {string} copy.mimeType - MIME type for the image file.
* @returns {File} Shareable image file.
* @see exportMakeup - Creates this file before invoking the Share API fallback.
* @see shareImageFile - Delivers the returned file when sharing is supported.
*/
export function makeImageFile(blob, copy) {
return new File([blob], copy.filename, { type: copy.mimeType });
}
/**
* Writes the exported PNG blob to the browser clipboard using `ClipboardItem`.
* This is the primary delivery path for the Copy Makeup action.
*
* @param {Blob} blob - PNG blob generated from the export canvas.
* @returns {Promise<void>} Resolves when the browser accepts the clipboard write.
* @see exportMakeup - Attempts this before falling back to Web Share.
* @see canUseClipboard - Guards Clipboard API support before writing.
*/
function copyBlobToClipboard(blob) {
if (!canUseClipboard(navigator)) {
return Promise.reject(new Error('Clipboard API not available.'));
}
const item = new ClipboardItem({ [EXPORT_COPY.mimeType]: blob });
return navigator.clipboard.write([item]);
}
/**
* Checks whether the current browser navigator exposes the image clipboard
* pieces needed by the Copy Makeup workflow.
*
* @param {Navigator|object} browserNavigator - Browser navigator or test double.
* @returns {boolean} True when `clipboard.write` and `ClipboardItem` are available.
* @see copyBlobToClipboard - Uses this before constructing a clipboard item.
*/
export function canUseClipboard(browserNavigator) {
return Boolean(
browserNavigator.clipboard &&
browserNavigator.clipboard.write &&
window.ClipboardItem
);
}
/**
* Shares the exported makeup PNG through the Web Share API when clipboard
* delivery fails, preserving the same project title and explanatory share text.
*
* @param {File} file - Exported makeup PNG file.
* @param {object} copy - Share title, text, and file metadata.
* @param {string} copy.shareTitle - Title passed to the Web Share API.
* @param {string} copy.shareText - Text passed to the Web Share API.
* @returns {Promise<void>} Resolves when the browser accepts the share request.
* @see exportMakeup - Calls this as the fallback after clipboard failure.
* @see canShareFile - Guards Web Share support before invoking `navigator.share`.
*/
function shareImageFile(file, copy) {
if (!canShareFile(file)) {
return Promise.reject(new Error('Share API not available for this file.'));
}
return navigator.share({
title: copy.shareTitle,
text: copy.shareText,
files: [file]
});
}
/**
* Checks whether the browser can share the generated makeup PNG. Browsers with
* `navigator.share` but no `navigator.canShare` are treated as share-capable,
* matching the fallback behavior expected by the lab UI.
*
* @param {File} file - Exported makeup PNG file.
* @returns {boolean} True when the Share API can be attempted for this file.
* @see shareImageFile - Uses this before calling `navigator.share`.
*/
export function canShareFile(file) {
if (!navigator.share) return false;
if (!navigator.canShare) return true;
return navigator.canShare({ files: [file] });
}