Technical documentation
Code
diff --git a/lab.html b/lab.html
index 3f4c4e3..dbcf34e 100644
--- a/lab.html
+++ b/lab.html
@@ -175,7 +175,34 @@
Settings
Upload & report
Send a recorded clip or evidence of a real-world deployment. Goes over the internet — share only what's safe for you.
-
Share a clip No clips recorded yet.
+
+
Share a clip
+
No clips recorded yet.
+
Record a short painted-face clip from the rail, then review it here before consenting to upload.
+
+
+
+
+
+ Note for moderators
+
+
+
+
+ I consent to send this painted-face clip to the Ghostmaxxing server for storage, human moderation, possible public web/feed/ActivityPub publication after approval, and deletion by private receipt token.
+
+
+ Submit to moderation
+ Discard clip
+
+
+
+
+
+
Receipt log
+
Private receipts from this browser. Keep them to reach the future public URL and revoke the upload with the delete token.
+
+
Report a deployment Where face recognition appears in public space, who runs it, what it looks like.
diff --git a/loader.html b/loader.html
new file mode 100644
index 0000000..823e934
--- /dev/null
+++ b/loader.html
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
Ghostmaxxing | Video Loader
+
+
+
+
+
+
+
+
+
+
+ Makeup Video Test Loader
+
+
+
+
Select a local MP4 video to begin.
+
+
+
+
+
+
+
+
+
+
+ Record face
+ Seek face
+
+ Faces in memory
+ 0
+ Next ID
+ 0
+ 2D threshold
+ 0.58
+ 0
+
+
+
+
+
+
Activity log
+
Each entry is captured at the exact video timestamp where the button was pressed.
+
+
+
+
+
+
+
+ 120 ms
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 8e34f36..1745324 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,13 @@
{
- "name": "setup-comprehensive-testing-framework",
- "version": "1.0.0",
+ "name": "ghostmaxxing",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "setup-comprehensive-testing-framework",
- "version": "1.0.0",
- "license": "ISC",
+ "name": "ghostmaxxing",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-or-later",
"devDependencies": {
"@playwright/test": "^1.60.0",
"@vitest/coverage-v8": "^4.1.8",
@@ -18,6 +18,9 @@
"jsdom": "^29.1.1",
"vite": "^8.0.16",
"vitest": "^4.1.8"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@asamuzakjp/css-color": {
diff --git a/package.json b/package.json
index cae0b91..b08a3be 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
"validate:ghostyle": "node scripts-dev/validate-plugin.js",
"validate-plugin": "npm run validate:ghostyle --",
"validate:ghostyles": "node -e \"const fs=require('node:fs'); const cp=require('node:child_process'); const files=fs.readdirSync('ghostyles').filter((f)=>f.endsWith('.js')).sort(); for (const f of files) cp.execFileSync(process.execPath, ['scripts-dev/validate-plugin.js', 'ghostyles/'+f], { stdio: 'inherit' });\"",
- "update:readme": "node scripts-dev/update-readme.js",
+ "update:coverage-badge": "node scripts-dev/update-coverage-badge.js",
"update:references": "node references/build-references-page.js",
"i18n:extract": "node scripts-dev/extract-i18n-pot.cjs",
"check": "npm run validate:ghostyles && npm run test:unit",
diff --git a/realtime.html b/realtime.html
new file mode 100644
index 0000000..69ce2c7
--- /dev/null
+++ b/realtime.html
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
Ghostmaxxing | Latent Space Visualizer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading models...
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/scripts-dev/update-coverage-badge.js b/scripts-dev/update-coverage-badge.js
new file mode 100644
index 0000000..5606bc8
--- /dev/null
+++ b/scripts-dev/update-coverage-badge.js
@@ -0,0 +1,93 @@
+#!/usr/bin/env node
+/**
+ * Updates only the README coverage badge block from the latest Vitest coverage report.
+ * This is intentionally narrower than scripts-dev/update-readme.js so CI can refresh
+ * the badge without rewriting commit metadata or changelog sections.
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const README_PATH = path.resolve(__dirname, '..', 'README.md');
+const COVERAGE_JSON_PATH = path.resolve(__dirname, '..', 'coverage', 'coverage-final.json');
+const COVERAGE_BADGE_START = '';
+const COVERAGE_BADGE_END = '';
+
+/**
+ * Computes statement coverage percentage from Vitest's coverage-final.json report.
+ *
+ * @returns {number|null} Percentage from 0 to 100, or null when the report is unavailable.
+ */
+function getCoverage() {
+ if (!fs.existsSync(COVERAGE_JSON_PATH)) return null;
+
+ let report;
+ try {
+ report = JSON.parse(fs.readFileSync(COVERAGE_JSON_PATH, 'utf8'));
+ } catch {
+ return null;
+ }
+
+ let total = 0;
+ let covered = 0;
+
+ for (const file of Object.values(report)) {
+ total += file.statementMap ? Object.keys(file.statementMap).length : 0;
+ if (!file.s) continue;
+ for (const count of Object.values(file.s)) {
+ if (count > 0) covered += 1;
+ }
+ }
+
+ if (!total) return null;
+ return (covered / total) * 100;
+}
+
+/**
+ * Builds a shields.io coverage badge URL from the computed percentage.
+ *
+ * @param {number|null} coverage Coverage percentage.
+ * @returns {string} Badge image URL.
+ */
+function getCoverageBadgeUrl(coverage) {
+ if (coverage == null) return 'https://img.shields.io/badge/coverage-UNKNOWN-lightgrey';
+ const pct = Number(coverage).toFixed(2);
+ const color = coverage >= 80 ? 'green' : coverage >= 50 ? 'yellow' : 'red';
+ return `https://img.shields.io/badge/coverage-${pct}%25-${color}`;
+}
+
+/**
+ * Upserts the README coverage badge block immediately after the first heading.
+ *
+ * @param {string} readme README content.
+ * @param {string} coverageBadgeBlock Replacement badge block.
+ * @returns {string} Updated README content.
+ */
+function upsertCoverageBadge(readme, coverageBadgeBlock) {
+ let next = readme.replace(
+ /[\s\S]*?\n?/m,
+ ''
+ );
+ next = next.replace(/^\s*\[!\[Unit Test Coverage\]\([^)]+\)\]\(coverage\/\)\s*\n?/gm, '');
+ next = next.replace(/^\s*!\[Unit Test Coverage\]\([^)]+\)\s*\n?/gm, '');
+
+ if (/^#{1,6}\s+.*$/m.test(next)) {
+ return next.replace(/^#{1,6}\s+.*$/m, (heading) => `${heading}\n${coverageBadgeBlock}`);
+ }
+
+ return `${coverageBadgeBlock}\n\n${next}`;
+}
+
+const coverage = getCoverage();
+const badgeUrl = getCoverageBadgeUrl(coverage);
+const coverageBadgeBlock = [
+ COVERAGE_BADGE_START,
+ `[](coverage/)`,
+ COVERAGE_BADGE_END,
+].join('\n');
+
+const readme = fs.readFileSync(README_PATH, 'utf8');
+const nextReadme = upsertCoverageBadge(readme, coverageBadgeBlock);
+
+fs.writeFileSync(README_PATH, nextReadme, 'utf8');
+console.log('README coverage badge updated');
\ No newline at end of file
diff --git a/scripts-dev/update-readme.js b/scripts-dev/update-readme.js
deleted file mode 100755
index 48ce79b..0000000
--- a/scripts-dev/update-readme.js
+++ /dev/null
@@ -1,159 +0,0 @@
-#!/usr/bin/env node
-/**
- * Auto‑update README on CI.
- *
- * What it does:
- * - Inserts/upserts a coverage badge (taken from the latest Vitest run).
- * - Inserts a “Last commit” line with short SHA and message.
- * - Optionally injects a small changelog of the last N commits.
- *
- * The script is pure‑JS so it runs both on GitHub Actions and locally.
- */
-
-const fs = require('fs');
-const path = require('path');
-const execSync = require('child_process').execSync;
-
-// ---------- Config ----------
-const README_PATH = path.resolve(__dirname, '..', 'README.md');
-const COVERAGE_BADGE_URL = (() => {
- const cov = getCoverage();
- if (cov != null) {
- const pct = Number(cov).toFixed(2);
- const color = cov >= 80 ? 'green' : cov >= 50 ? 'yellow' : 'red';
- return `https://img.shields.io/badge/coverage-${pct}%25-${color}`;
- }
- return 'https://img.shields.io/badge/coverage-UNKNOWN-lightgrey';
-})();
-const CHANGELOG_COMMITS = 5; // how many recent commits to list
-const COVERAGE_BADGE_START = '';
-const COVERAGE_BADGE_END = '';
-
-// ---------------------------
-// Helper to compute coverage percentage from the JSON report produced by Vitest.
-function getCoverage() {
- const coverageRoot = path.resolve(__dirname, '..', 'coverage');
- const jsonPath = path.join(coverageRoot, 'coverage-final.json');
-
- // If the JSON report does not exist, fall back to null.
- if (!fs.existsSync(jsonPath)) return null;
-
- // The JSON format contains an object keyed by file paths.
- // Each file entry has a `statementMap` (all statements) and an `f` map
- // of execution counts. The sum of the statementMap entries is the total
- // number of statements, and the sum of the `f` values is the number of
- // statements actually executed.
- const raw = fs.readFileSync(jsonPath, 'utf8');
- let report;
- try {
- report = JSON.parse(raw);
- } catch {
- // If parsing fails we cannot determine coverage.
- return null;
- }
-
- let total = 0;
- let covered = 0;
-
- for (const file of Object.values(report)) {
- // `statementMap` contains every statement in the file.
- const statements = file.statementMap ? Object.keys(file.statementMap).length : 0;
- total += statements;
-
- // `s` maps statement IDs to execution counts.
- if (file.s) {
- for (const count of Object.values(file.s)) {
- if (count > 0) covered += 1;
- }
- }
- }
-
- if (!total) return null;
- return (covered / total) * 100;
-}
-
-// Helper to run a git command and get trimmed stdout
-const git = (cmd) => execSync(`git ${cmd}`, { encoding: 'utf8' }).trim();
-
-// ------------------------------------------------------------------
-// 1️⃣ Gather data
-// ------------------------------------------------------------------
-const latestCommitSha = git('rev-parse --short HEAD');
-const latestCommitMsg = git('log -1 --pretty=%s');
-const recentCommits = git(`log -${CHANGELOG_COMMITS} --pretty=%h:%s`).split('\n');
-
-// ------------------------------------------------------------------
-// 2️⃣ Read current README
-// ------------------------------------------------------------------
-let readme = fs.readFileSync(README_PATH, 'utf8');
-
-// ------------------------------------------------------------------
-// 3️⃣ Replace/Insert the badge line (just after the title)
-// ------------------------------------------------------------------
-const coverageBadgeBlock = [
- COVERAGE_BADGE_START,
- `[](coverage/)`,
- COVERAGE_BADGE_END,
-].join('\n');
-
-// Ensure there is exactly one coverage badge block by removing any old block
-// or legacy standalone badges first.
-readme = readme.replace(
- /[\s\S]*?\n?/m,
- ''
-);
-readme = readme.replace(/^\s*\[!\[Unit Test Coverage\]\([^)]+\)\]\(coverage\/\)\s*\n?/gm, '');
-readme = readme.replace(/^\s*\!\[Unit Test Coverage\]\([^)]+\)\s*\n?/gm, '');
-
-// Re-insert a single badge right after the first heading.
-if (/^#{1,6}\s+.*$/m.test(readme)) {
- readme = readme.replace(
- /^#{1,6}\s+.*$/m,
- (heading) => `${heading}\n${coverageBadgeBlock}`
- );
-} else {
- // Fallback for malformed README files without headings.
- readme = `${coverageBadgeBlock}\n\n${readme}`;
-}
-
-// ------------------------------------------------------------------
-// 4️⃣ Insert a “Last commit” line
-// ------------------------------------------------------------------
-const lastCommitLine = `\n**Last commit:** \`${latestCommitSha}\` – ${latestCommitMsg}\n`;
-if (!readme.includes('**Last commit:**')) {
- // Append at the end of file if not present
- readme += lastCommitLine;
-} else {
- // Replace the existing line
- readme = readme.replace(
- /\*\*Last commit:\*\* .*\n/,
- `${lastCommitLine}\n`
- );
-}
-
-// ------------------------------------------------------------------
-// 5️⃣ Optional tiny changelog section
-// ------------------------------------------------------------------
-const changelogHeader = '\n## Recent changes\n';
-let changelog = recentCommits
- .map((c) => {
- const [sha, msg] = c.split(':');
- return `- \`${sha}\` ${msg}`;
- })
- .join('\n');
-
-if (!readme.includes('## Recent changes')) {
- readme += `${changelogHeader}${changelog}\n`;
-} else {
- // Replace old block (simple regex, good enough for a small README)
- readme = readme.replace(
- /## Recent changes[\s\S]*?(?=\n##|$)/,
- `${changelogHeader}${changelog}`
- );
-}
-
-// ------------------------------------------------------------------
-// 6️⃣ Write back
-// ------------------------------------------------------------------
-fs.writeFileSync(README_PATH, readme, 'utf8');
-console.log('✅ README updated');
diff --git a/scripts/camera.js b/scripts/camera.js
index c801cb0..5de5e0b 100644
--- a/scripts/camera.js
+++ b/scripts/camera.js
@@ -166,9 +166,8 @@ export function stopEffectLoop() {
}
/**
- * Capture a short video clip from the live webcam stream and either trigger
- * a browser download or POST it to the configured upload endpoint
- * (`RECORDING_CONFIG.mode`). Honours `state.isRecording` and
+ * Capture a short video clip from the live webcam stream and emit it as a
+ * browser-managed Blob for the upload/consent panel. Honours `state.isRecording` and
* `state.isSystemBusy` to refuse overlapping captures, and updates the
* record button visual state for the duration of the recording.
*
@@ -228,43 +227,25 @@ export async function recordOneSecond() {
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: mimeType });
- const isUploadMode = RECORDING_CONFIG.mode === 'upload';
+ const filename = `ghostati-recording-${Date.now()}.${extension}`;
+ const clipId = globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function'
+ ? globalThis.crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
- if (isUploadMode) {
- setLog(t('video_uploading_log'));
- try {
- const formData = new FormData();
- const filename = `ghostati-recording-${Date.now()}.${extension}`;
- formData.append('video', blob, filename);
+ const detail = {
+ id: clipId,
+ blob,
+ filename,
+ mimeType,
+ extension,
+ size: blob.size,
+ durationMs: RECORDING_CONFIG.durationMs,
+ recordedAt: new Date().toISOString(),
+ ghostyleId: state.activeEffect || null
+ };
- const response = await fetch(RECORDING_CONFIG.uploadEndpoint, {
- method: 'POST',
- body: formData
- });
-
- if (response.ok) {
- setLog(t('video_upload_done_log', { status: response.status, filename }));
- } else {
- setLog(t('video_upload_http_error_log', { status: response.status, statusText: response.statusText }));
- }
- } catch (err) {
- setLog(t('video_upload_network_error_log', { message: err.message }));
- }
- } else {
- // Direct download mode
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.style.display = 'none';
- a.href = url;
- a.download = `ghostati-recording-${Date.now()}.${extension}`;
- document.body.appendChild(a);
- a.click();
- setTimeout(() => {
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }, 100);
- setLog(t('recording_downloaded_log', { filename: a.download }));
- }
+ state.gstmxxEvents.dispatchEvent(new CustomEvent('clipRecorded', { detail }));
+ setLog(t('recording_queued_log', { filename }));
// Reset visual styles and state
state.isRecording = false;
diff --git a/scripts/config.js b/scripts/config.js
index b8f51bc..d41b907 100644
--- a/scripts/config.js
+++ b/scripts/config.js
@@ -110,18 +110,22 @@ export const DETECTOR_OPTIONS = new faceapi.TinyFaceDetectorOptions({
* @see scripts/camera.js – `recordOneSecond()` reads `mode`, `uploadEndpoint`, `durationMs`.
*/
export const RECORDING_CONFIG = {
- // Mode of operation: 'download' (triggers direct browser download) or
- // 'upload' (POSTs to uploadEndpoint).
- mode: 'download',
+ // Mode of operation: 'queue' keeps the recorded Blob in the browser so the
+ // upload/consent flow can decide whether it ever leaves the device.
+ mode: 'queue',
- // Endpoint used in 'upload' mode.
+ // Endpoint used by the consented submit action.
// Expected backend contract:
// - Method: POST
// - Content-Type: multipart/form-data
// - Payload field: 'video' (the recording blob)
- // - Response: any 2xx status counts as success; anything else is an error.
- uploadEndpoint: 'http://localhost:3000/upload',
+ // - Required field: 'consent_version'
+ // - Response: { ok, uploadId, deleteToken }
+ uploadEndpoint: '/api/uploads',
// Duration of the recording in milliseconds (default: 2 seconds).
durationMs: 2000
};
+
+export const UPLOAD_CONSENT_VERSION = '2026-07-v1';
+export const APP_VERSION = '0.1.0';
diff --git a/scripts/i18n.js b/scripts/i18n.js
index df6fe20..e491ea5 100644
--- a/scripts/i18n.js
+++ b/scripts/i18n.js
@@ -24,6 +24,323 @@ export const localeFlags = {
export const LOCALE_STORAGE_KEY = 'ghostmaxxing-locale';
export const messages = {
+ transfer_page_title: {
+ context: 'ghostyle-transfer.html:7; scripts/transfer.js:143',
+ it: 'Trasferimento Ghostyle — Ghostmaxxing',
+ en: 'Ghostyle Transfer — Ghostmaxxing',
+ pt: 'Transferencia de Ghostyle — Ghostmaxxing',
+ notes: 'Glossary: Ghostyle.',
+ },
+ transfer_kicker: {
+ context: 'ghostyle-transfer.html:50',
+ it: 'Lab · trasferimento Ghostyle',
+ en: 'Lab · Ghostyle transfer',
+ pt: 'Lab · transferencia de Ghostyle',
+ notes: 'Glossary: Ghostyle.',
+ },
+ transfer_title: {
+ context: 'ghostyle-transfer.html:51',
+ it: 'Solleva un Ghostyle da un volto, indossalo su un altro.',
+ en: 'Lift a Ghostyle off one face, wear it on another.',
+ pt: 'Extraia um Ghostyle de um rosto e use em outro.',
+ notes: 'Glossary: Ghostyle.',
+ },
+ transfer_lead: {
+ context: 'ghostyle-transfer.html:52',
+ it: 'Estrai il trucco da una coppia prima / dopo del workshop e posizionalo su un volto target.',
+ en: 'Extract the paint from a workshop before / after pair and place it on a target face.',
+ pt: 'Extraia a pintura de um par antes / depois do workshop e coloque-a em um rosto alvo.',
+ },
+ transfer_deck: {
+ context: 'ghostyle-transfer.html:55',
+ it: 'Segna il volto su ogni immagine con un riquadro. Il trasferimento avviene interamente su questo dispositivo, con allineamento mesh quando il motore locale riesce a rilevare landmark compatibili.',
+ en: 'Mark the face on each image with a box. The transfer runs entirely on this device, with mesh alignment when the local face engine can detect matching landmarks.',
+ pt: 'Marque o rosto em cada imagem com uma caixa. A transferencia roda inteiramente neste dispositivo, com alinhamento por malha quando o motor local consegue detectar landmarks compativeis.',
+ notes: 'Glossary: mesh, landmarks.',
+ },
+ transfer_engine_loading: {
+ context: 'ghostyle-transfer.html:60; scripts/transfer.js:26',
+ it: 'Motore mesh: caricamento…',
+ en: 'Mesh engine: loading…',
+ pt: 'Motor de malha: carregando…',
+ notes: 'Glossary: mesh.',
+ },
+ transfer_engine_unavailable: {
+ context: 'scripts/transfer.js:160',
+ it: 'Motore mesh: non disponibile, solo modalità riquadro',
+ en: 'Mesh engine: unavailable, box mode only',
+ pt: 'Motor de malha: indisponivel, somente modo caixa',
+ notes: 'Glossary: mesh.',
+ },
+ transfer_engine_ready: {
+ context: 'scripts/transfer.js:169',
+ it: 'Motore mesh: pronto (face-api 68pt)',
+ en: 'Mesh engine: ready (face-api 68pt)',
+ pt: 'Motor de malha: pronto (face-api 68pt)',
+ notes: 'Glossary: mesh, face-api.',
+ },
+ transfer_engine_failed: {
+ context: 'scripts/transfer.js:171',
+ it: 'Motore mesh: modelli non caricati, solo modalità riquadro',
+ en: 'Mesh engine: models failed, box mode only',
+ pt: 'Motor de malha: modelos falharam, somente modo caixa',
+ notes: 'Glossary: mesh.',
+ },
+ transfer_workbench_label: {
+ context: 'ghostyle-transfer.html:65',
+ it: 'Banco di lavoro per trasferimento Ghostyle',
+ en: 'Ghostyle transfer workbench',
+ pt: 'Bancada de transferencia de Ghostyle',
+ notes: 'Glossary: Ghostyle.',
+ },
+ transfer_before_title: {
+ context: 'ghostyle-transfer.html:70',
+ it: 'Prima',
+ en: 'Before',
+ pt: 'Antes',
+ },
+ transfer_before_tag: {
+ context: 'ghostyle-transfer.html:71',
+ it: 'volto senza trucco · opzionale',
+ en: 'bare face · optional',
+ pt: 'rosto sem pintura · opcional',
+ },
+ transfer_before_hint: {
+ context: 'ghostyle-transfer.html:74',
+ it: 'Trascina o scegli il volto senza trucco del workshop',
+ en: 'Drop or choose the bare workshop face',
+ pt: 'Solte ou escolha o rosto sem pintura do workshop',
+ },
+ transfer_after_title: {
+ context: 'ghostyle-transfer.html:86',
+ it: 'Dopo',
+ en: 'After',
+ pt: 'Depois',
+ },
+ transfer_after_tag: {
+ context: 'ghostyle-transfer.html:87',
+ it: 'volto truccato',
+ en: 'painted face',
+ pt: 'rosto pintado',
+ },
+ transfer_after_hint: {
+ context: 'ghostyle-transfer.html:90',
+ it: 'Trascina o scegli il volto truccato del workshop',
+ en: 'Drop or choose the painted workshop face',
+ pt: 'Solte ou escolha o rosto pintado do workshop',
+ },
+ transfer_target_title: {
+ context: 'ghostyle-transfer.html:102',
+ it: 'Target',
+ en: 'Target',
+ pt: 'Alvo',
+ },
+ transfer_target_tag: {
+ context: 'ghostyle-transfer.html:103',
+ it: 'riceve il trucco',
+ en: 'receives the paint',
+ pt: 'recebe a pintura',
+ },
+ transfer_target_hint: {
+ context: 'ghostyle-transfer.html:106',
+ it: 'Trascina o scegli il volto / dipinto target',
+ en: 'Drop or choose the target face / painting',
+ pt: 'Solte ou escolha o rosto / pintura alvo',
+ },
+ transfer_choose_image: {
+ context: 'ghostyle-transfer.html:79; ghostyle-transfer.html:95; ghostyle-transfer.html:111',
+ it: 'Scegli immagine',
+ en: 'Choose image',
+ pt: 'Escolher imagem',
+ },
+ transfer_sensitivity_label: {
+ context: 'ghostyle-transfer.html:120',
+ it: 'Sensibilità trucco',
+ en: 'Paint sensitivity',
+ pt: 'Sensibilidade da pintura',
+ },
+ transfer_feather_label: {
+ context: 'ghostyle-transfer.html:124',
+ it: 'Sfumatura bordo',
+ en: 'Edge feather',
+ pt: 'Suavizacao da borda',
+ },
+ transfer_opacity_label: {
+ context: 'ghostyle-transfer.html:128',
+ it: 'Opacità',
+ en: 'Opacity',
+ pt: 'Opacidade',
+ },
+ transfer_blend_label: {
+ context: 'ghostyle-transfer.html:132',
+ it: 'Fusione',
+ en: 'Blend',
+ pt: 'Mesclagem',
+ },
+ transfer_blend_multiply: {
+ context: 'ghostyle-transfer.html:134',
+ it: 'Moltiplica (dipinge sopra)',
+ en: 'Multiply (paints onto)',
+ pt: 'Multiplicar (pinta por cima)',
+ },
+ transfer_blend_normal: {
+ context: 'ghostyle-transfer.html:135',
+ it: 'Normale',
+ en: 'Normal',
+ pt: 'Normal',
+ },
+ transfer_blend_soft_light: {
+ context: 'ghostyle-transfer.html:136',
+ it: 'Luce soffusa',
+ en: 'Soft light',
+ pt: 'Luz suave',
+ },
+ transfer_use_mesh_label: {
+ context: 'ghostyle-transfer.html:142',
+ it: 'Segui i lineamenti (mesh)',
+ en: 'Follow features (mesh)',
+ pt: 'Seguir feicoes (malha)',
+ notes: 'Glossary: mesh.',
+ },
+ transfer_run_button: {
+ context: 'ghostyle-transfer.html:147',
+ it: 'Trasferisci Ghostyle',
+ en: 'Transfer Ghostyle',
+ pt: 'Transferir Ghostyle',
+ notes: 'Glossary: Ghostyle.',
+ },
+ transfer_download_button: {
+ context: 'ghostyle-transfer.html:148',
+ it: 'Scarica risultato',
+ en: 'Download result',
+ pt: 'Baixar resultado',
+ },
+ transfer_reset_button: {
+ context: 'ghostyle-transfer.html:149',
+ it: 'Reimposta',
+ en: 'Reset',
+ pt: 'Redefinir',
+ },
+ transfer_msg_load_after_target: {
+ context: 'ghostyle-transfer.html:150; scripts/transfer.js:27; scripts/transfer.js:337',
+ it: 'Carica un dopo e un target, poi disegna un riquadro su ogni volto.',
+ en: 'Load an after and a target, then draw a box on each face.',
+ pt: 'Carregue um depois e um alvo, depois desenhe uma caixa em cada rosto.',
+ },
+ transfer_msg_ready: {
+ context: 'scripts/transfer.js:337',
+ it: 'Pronto. Premi Trasferisci Ghostyle.',
+ en: 'Ready. Press Transfer Ghostyle.',
+ pt: 'Pronto. Pressione Transferir Ghostyle.',
+ notes: 'Glossary: Ghostyle.',
+ },
+ transfer_msg_extracting: {
+ context: 'scripts/transfer.js:670',
+ it: 'Estrazione del trucco…',
+ en: 'Extracting paint…',
+ pt: 'Extraindo pintura…',
+ },
+ transfer_msg_detecting: {
+ context: 'scripts/transfer.js:699',
+ it: 'Rilevamento landmark…',
+ en: 'Detecting landmarks…',
+ pt: 'Detectando landmarks…',
+ notes: 'Glossary: landmarks.',
+ },
+ transfer_msg_done: {
+ context: 'scripts/transfer.js:753',
+ it: 'Fatto.',
+ en: 'Done.',
+ pt: 'Concluido.',
+ },
+ transfer_msg_error: {
+ context: 'scripts/transfer.js:807',
+ it: 'Errore: {message}',
+ en: 'Error: {message}',
+ pt: 'Erro: {message}',
+ },
+ transfer_note_box_set: {
+ context: 'scripts/transfer.js:316',
+ it: 'riquadro volto impostato',
+ en: 'face box set',
+ pt: 'caixa do rosto definida',
+ },
+ transfer_note_draw_box: {
+ context: 'scripts/transfer.js:318',
+ it: 'disegna un riquadro sul volto',
+ en: 'draw a box over the face',
+ pt: 'desenhe uma caixa sobre o rosto',
+ },
+ transfer_result_empty: {
+ context: 'ghostyle-transfer.html:155; scripts/transfer.js:126',
+ it: 'Il risultato trasferito apparirà qui.',
+ en: 'The transferred result will appear here.',
+ pt: 'O resultado transferido aparecera aqui.',
+ },
+ transfer_matte_title: {
+ context: 'ghostyle-transfer.html:160',
+ it: 'Trucco estratto',
+ en: 'Extracted paint',
+ pt: 'Pintura extraida',
+ },
+ transfer_matte_note: {
+ context: 'ghostyle-transfer.html:162',
+ it: 'Il Ghostyle isolato, in un frame volto normalizzato.',
+ en: 'The isolated Ghostyle, in a normalized face frame.',
+ pt: 'O Ghostyle isolado, em um quadro de rosto normalizado.',
+ notes: 'Glossary: Ghostyle.',
+ },
+ transfer_mode_title: {
+ context: 'ghostyle-transfer.html:165',
+ it: 'Modalità',
+ en: 'Mode',
+ pt: 'Modo',
+ },
+ transfer_mode_not_run: {
+ context: 'ghostyle-transfer.html:166; scripts/transfer.js:28',
+ it: 'Non ancora eseguito.',
+ en: 'Not run yet.',
+ pt: 'Ainda nao executado.',
+ },
+ transfer_mode_placed: {
+ context: 'scripts/transfer.js:103',
+ it: 'Posizionato con: {mode}',
+ en: 'Placed using: {mode}',
+ pt: 'Posicionado usando: {mode}',
+ },
+ transfer_mode_box: {
+ context: 'scripts/transfer.js:697',
+ it: 'riquadro',
+ en: 'box',
+ pt: 'caixa',
+ },
+ transfer_mode_mesh: {
+ context: 'scripts/transfer.js:736',
+ it: 'mesh ({count} triangoli)',
+ en: 'mesh ({count} triangles)',
+ pt: 'malha ({count} triangulos)',
+ notes: 'Glossary: mesh.',
+ },
+ transfer_mode_box_no_face: {
+ context: 'scripts/transfer.js:748',
+ it: 'riquadro (nessun volto rilevato)',
+ en: 'box (no face detected)',
+ pt: 'caixa (nenhum rosto detectado)',
+ },
+ transfer_mode_box_unavailable: {
+ context: 'scripts/transfer.js:750',
+ it: 'riquadro (motore mesh non disponibile)',
+ en: 'box (mesh engine unavailable)',
+ pt: 'caixa (motor de malha indisponivel)',
+ notes: 'Glossary: mesh.',
+ },
+ transfer_disclaimer: {
+ context: 'ghostyle-transfer.html:174',
+ it: 'Ghostmaxxing è uno strumento di ricerca e formazione. Trasferire un Ghostyle su un volto è una visualizzazione; non è una prova che il pattern influisca su un sistema di riconoscimento. I rilevatori di volti sono inaffidabili sulle opere stilizzate. Se la mesh non trova un volto target, lo strumento usa il riquadro che hai disegnato.',
+ en: 'Ghostmaxxing is a research and education tool. Transferring a Ghostyle onto a face is a visualization; it is not evidence that the pattern affects any recognition system. Face detectors are unreliable on stylized artwork. If the mesh cannot find a target face, the tool uses the box you drew.',
+ pt: 'Ghostmaxxing e uma ferramenta de pesquisa e educacao. Transferir um Ghostyle para um rosto e uma visualizacao; nao e prova de que o padrao afete qualquer sistema de reconhecimento. Detectores de rosto nao sao confiaveis em arte estilizada. Se a malha nao encontrar um rosto alvo, a ferramenta usa a caixa que voce desenhou.',
+ notes: 'Glossary: Ghostyle, mesh, face recognition.',
+ },
status_initialising: {
context: 'lab.html:30',
it: 'inizializzazione…',
@@ -450,6 +767,192 @@ export const messages = {
en: '{count} clip(s) ready to send.',
pt: '{count} clipe(s) prontos para enviar.',
},
+ upload_record_first_help: {
+ context: 'lab.html:179',
+ it: 'Registra una breve clip con il volto truccato dalla barra laterale, poi rivedila qui prima di consentire l\'upload.',
+ en: 'Record a short painted-face clip from the rail, then review it here before consenting to upload.',
+ pt: 'Grave um clipe curto com o rosto pintado pela barra lateral e revise aqui antes de consentir o envio.',
+ },
+ upload_clip_list_label: {
+ context: 'lab.html:183',
+ it: 'Clip registrate',
+ en: 'Recorded clips',
+ pt: 'Clipes gravados',
+ },
+ upload_note_label: {
+ context: 'lab.html:186',
+ it: 'Nota per i moderatori',
+ en: 'Note for moderators',
+ pt: 'Nota para moderadores',
+ },
+ upload_note_placeholder: {
+ context: 'lab.html:187',
+ it: 'Cosa mostra questa clip?',
+ en: 'What is this clip showing?',
+ pt: 'O que este clipe mostra?',
+ },
+ upload_consent_copy: {
+ context: 'lab.html:191',
+ it: 'Acconsento a inviare questa clip con volto truccato al server Ghostmaxxing per archiviazione, moderazione umana, possibile pubblicazione web/feed/ActivityPub dopo approvazione, e cancellazione tramite ricevuta privata.',
+ en: 'I consent to send this painted-face clip to the Ghostmaxxing server for storage, human moderation, possible public web/feed/ActivityPub publication after approval, and deletion by private receipt token.',
+ pt: 'Consinto enviar este clipe com rosto pintado ao servidor Ghostmaxxing para armazenamento, moderação humana, possível publicação web/feed/ActivityPub após aprovação e exclusão por recibo privado.',
+ },
+ upload_submit_button: {
+ context: 'lab.html:194',
+ it: 'Invia in moderazione',
+ en: 'Submit to moderation',
+ pt: 'Enviar para moderação',
+ },
+ upload_discard_button: {
+ context: 'lab.html:195',
+ it: 'Scarta clip',
+ en: 'Discard clip',
+ pt: 'Descartar clipe',
+ },
+ upload_meta_recorded_label: {
+ context: 'scripts/upload-consent.js',
+ it: 'Registrata',
+ en: 'Recorded',
+ pt: 'Gravado',
+ },
+ upload_meta_size_label: {
+ context: 'scripts/upload-consent.js',
+ it: 'Dimensione',
+ en: 'Size',
+ pt: 'Tamanho',
+ },
+ upload_meta_ghostyle_label: {
+ context: 'scripts/upload-consent.js',
+ it: 'Ghostyle',
+ en: 'Ghostyle',
+ pt: 'Ghostyle',
+ },
+ upload_meta_result_label: {
+ context: 'scripts/upload-consent.js',
+ it: 'Risultato',
+ en: 'Result',
+ pt: 'Resultado',
+ },
+ upload_clip_ready_status: {
+ context: 'scripts/upload-consent.js',
+ it: 'Clip pronta nel browser. Nessun upload finche non dai consenso.',
+ en: 'Clip ready in the browser. Nothing uploads until you consent.',
+ pt: 'Clipe pronto no navegador. Nada é enviado até você consentir.',
+ },
+ upload_clip_discarded_status: {
+ context: 'scripts/upload-consent.js',
+ it: 'Clip scartata.',
+ en: 'Clip discarded.',
+ pt: 'Clipe descartado.',
+ },
+ upload_receipt_created_status: {
+ context: 'scripts/upload-consent.js',
+ it: 'Upload ricevuto. La ricevuta privata è nel log.',
+ en: 'Upload received. The private receipt is in the log.',
+ pt: 'Envio recebido. O recibo privado está no registro.',
+ },
+ receipt_log_title: {
+ context: 'lab.html:201',
+ it: 'Log ricevute',
+ en: 'Receipt log',
+ pt: 'Registro de recibos',
+ },
+ receipt_log_description: {
+ context: 'lab.html:202',
+ it: 'Ricevute private da questo browser. Conservale per raggiungere il futuro URL pubblico e revocare l\'upload con il delete token.',
+ en: 'Private receipts from this browser. Keep them to reach the future public URL and revoke the upload with the delete token.',
+ pt: 'Recibos privados deste navegador. Guarde-os para acessar o futuro URL público e revogar o envio com o token de exclusão.',
+ },
+ receipt_log_empty: {
+ context: 'scripts/upload-consent.js',
+ it: 'Nessuna ricevuta salvata in questo browser.',
+ en: 'No receipts saved in this browser.',
+ pt: 'Nenhum recibo salvo neste navegador.',
+ },
+ receipt_status_pending: {
+ context: 'scripts/upload-consent.js',
+ it: 'in moderazione',
+ en: 'pending',
+ pt: 'pendente',
+ },
+ receipt_status_deleted: {
+ context: 'scripts/upload-consent.js',
+ it: 'revocata',
+ en: 'revoked',
+ pt: 'revogado',
+ },
+ receipt_status_delete_failed: {
+ context: 'scripts/upload-consent.js',
+ it: 'revoca fallita',
+ en: 'revoke failed',
+ pt: 'revogação falhou',
+ },
+ receipt_status_imported: {
+ context: 'scripts/upload-consent.js',
+ it: 'importata',
+ en: 'imported',
+ pt: 'importado',
+ },
+ receipt_upload_id_label: {
+ context: 'scripts/upload-consent.js',
+ it: 'Upload ID',
+ en: 'Upload ID',
+ pt: 'ID do envio',
+ },
+ receipt_consent_label: {
+ context: 'scripts/upload-consent.js',
+ it: 'Consenso',
+ en: 'Consent',
+ pt: 'Consentimento',
+ },
+ receipt_open_public_button: {
+ context: 'scripts/upload-consent.js',
+ it: 'Apri URL pubblico',
+ en: 'Open public URL',
+ pt: 'Abrir URL público',
+ },
+ receipt_copy_private_button: {
+ context: 'scripts/upload-consent.js',
+ it: 'Copia ricevuta privata',
+ en: 'Copy private receipt',
+ pt: 'Copiar recibo privado',
+ },
+ receipt_revoke_button: {
+ context: 'scripts/upload-consent.js',
+ it: 'Revoca',
+ en: 'Revoke',
+ pt: 'Revogar',
+ },
+ receipt_copied_status: {
+ context: 'scripts/upload-consent.js',
+ it: 'Ricevuta privata copiata.',
+ en: 'Private receipt copied.',
+ pt: 'Recibo privado copiado.',
+ },
+ receipt_copy_failed_status: {
+ context: 'scripts/upload-consent.js',
+ it: 'Impossibile copiare la ricevuta privata.',
+ en: 'Could not copy the private receipt.',
+ pt: 'Não foi possível copiar o recibo privado.',
+ },
+ receipt_revoke_working_status: {
+ context: 'scripts/upload-consent.js',
+ it: 'Revoca in corso...',
+ en: 'Revoking...',
+ pt: 'Revogando...',
+ },
+ receipt_revoke_done_status: {
+ context: 'scripts/upload-consent.js',
+ it: 'Upload revocato.',
+ en: 'Upload revoked.',
+ pt: 'Envio revogado.',
+ },
+ receipt_revoke_failed_status: {
+ context: 'scripts/upload-consent.js',
+ it: 'Revoca non riuscita: {message}',
+ en: 'Revoke failed: {message}',
+ pt: 'Revogação falhou: {message}',
+ },
report_deployment_title: {
context: 'lab.html:175',
it: 'Segnala un deployment',
@@ -849,6 +1352,12 @@ export const messages = {
en: 'Recording started...',
pt: 'Gravação iniciada...',
},
+ recording_queued_log: {
+ context: 'scripts/camera.js:248',
+ it: 'Registrazione pronta nel browser: {filename}',
+ en: 'Recording ready in the browser: {filename}',
+ pt: 'Gravação pronta no navegador: {filename}',
+ },
video_uploading_log: {
context: 'scripts/camera.js:233',
it: 'Caricamento del video sul server in corso...',
@@ -1668,6 +2177,213 @@ export const messages = {
en: '[auto-find-loop] tick error:',
pt: '[auto-find-loop] erro de tick:',
},
+ loader_page_title: {
+ context: 'loader.html:7',
+ it: 'Ghostmaxxing | Loader video',
+ en: 'Ghostmaxxing | Video Loader',
+ pt: 'Ghostmaxxing | Carregador de vídeo',
+ },
+ loader_meta_description: {
+ context: 'loader.html:8',
+ it: 'Strumento interno Ghostmaxxing per testare video MP4 locali di makeup contro i motori facciali 2D e 3D.',
+ en: 'Internal Ghostmaxxing tool for testing local MP4 makeup videos against the 2D and 3D face engines.',
+ pt: 'Ferramenta interna do Ghostmaxxing para testar vídeos MP4 locais de maquiagem contra os motores faciais 2D e 3D.',
+ notes: 'Glossary: MP4, face engines, 2D, 3D.',
+ },
+ loader_wordmark_label: {
+ context: 'loader.html:19',
+ it: 'Homepage Ghostmaxxing',
+ en: 'Ghostmaxxing homepage',
+ pt: 'Página inicial Ghostmaxxing',
+ },
+ loader_subtitle: {
+ context: 'loader.html:24',
+ it: 'Loader video interno',
+ en: 'Internal video loader',
+ pt: 'Carregador interno de vídeo',
+ },
+ loader_loading_models_status: {
+ context: 'loader.html:32',
+ it: 'caricamento modelli',
+ en: 'loading models',
+ pt: 'carregando modelos',
+ },
+ loader_title: {
+ context: 'loader.html:37',
+ it: 'Loader di test video makeup',
+ en: 'Makeup Video Test Loader',
+ pt: 'Carregador de teste de vídeo de maquiagem',
+ },
+ loader_stage_label: {
+ context: 'loader.html:39',
+ it: 'Carica e riproduci video',
+ en: 'Load and play video',
+ pt: 'Carregar e reproduzir vídeo',
+ },
+ loader_placeholder: {
+ context: 'loader.html:41',
+ it: 'Seleziona un video MP4 locale per iniziare.',
+ en: 'Select a local MP4 video to begin.',
+ pt: 'Selecione um vídeo MP4 local para começar.',
+ notes: 'Glossary: MP4.',
+ },
+ loader_load_mp4: {
+ context: 'loader.html:50',
+ it: 'Carica MP4',
+ en: 'Load MP4',
+ pt: 'Carregar MP4',
+ notes: 'Glossary: MP4.',
+ },
+ loader_timeline_label: {
+ context: 'loader.html:53',
+ it: 'Avanzamento video',
+ en: 'Video progress',
+ pt: 'Progresso do vídeo',
+ },
+ loader_actions_label: {
+ context: 'loader.html:63',
+ it: 'Azioni volto',
+ en: 'Face actions',
+ pt: 'Ações de rosto',
+ },
+ loader_record_face_button: {
+ context: 'loader.html:64; scripts/loader.js:250',
+ it: 'Registra volto',
+ en: 'Record face',
+ pt: 'Gravar rosto',
+ },
+ loader_seek_face_button: {
+ context: 'loader.html:65; scripts/loader.js:303',
+ it: 'Cerca volto',
+ en: 'Seek face',
+ pt: 'Buscar rosto',
+ },
+ loader_faces_in_memory: {
+ context: 'loader.html:67',
+ it: 'Volti in memoria',
+ en: 'Faces in memory',
+ pt: 'Rostos na memória',
+ },
+ loader_2d_threshold: {
+ context: 'loader.html:71',
+ it: 'Soglia 2D',
+ en: '2D threshold',
+ pt: 'Limiar 2D',
+ notes: 'Glossary: threshold, 2D.',
+ },
+ loader_log_label: {
+ context: 'loader.html:78',
+ it: 'Registro attività pulsanti',
+ en: 'Button activity log',
+ pt: 'Registro de atividade dos botões',
+ },
+ loader_activity_log_title: {
+ context: 'loader.html:80',
+ it: 'Registro attività',
+ en: 'Activity log',
+ pt: 'Registro de atividade',
+ },
+ loader_activity_log_description: {
+ context: 'loader.html:81',
+ it: 'Ogni voce viene catturata al timestamp video esatto in cui è stato premuto il pulsante.',
+ en: 'Each entry is captured at the exact video timestamp where the button was pressed.',
+ pt: 'Cada entrada é capturada no timestamp exato do vídeo em que o botão foi pressionado.',
+ notes: 'Glossary: timestamp.',
+ },
+ loader_status_action_failed: {
+ context: 'scripts/loader.js:222',
+ it: 'azione fallita',
+ en: 'action failed',
+ pt: 'ação falhou',
+ },
+ loader_status_loading_faceapi: {
+ context: 'scripts/loader.js:396',
+ it: 'caricamento face-api',
+ en: 'loading face-api',
+ pt: 'carregando face-api',
+ notes: 'Glossary: face-api.',
+ },
+ loader_status_loading_3d_embedder: {
+ context: 'scripts/loader.js:404',
+ it: 'caricamento embedder 3D',
+ en: 'loading 3D embedder',
+ pt: 'carregando embedder 3D',
+ notes: 'Glossary: 3D embedder.',
+ },
+ loader_status_ready: {
+ context: 'scripts/loader.js:474',
+ it: 'pronto',
+ en: 'ready',
+ pt: 'pronto',
+ },
+ loader_status_model_load_failed: {
+ context: 'scripts/loader.js:480',
+ it: 'caricamento modelli fallito',
+ en: 'model load failed',
+ pt: 'falha ao carregar modelos',
+ },
+ loader_log_loaded_local_video: {
+ context: 'scripts/loader.js:428',
+ it: 'Video locale caricato: {name}',
+ en: 'Loaded local video: {name}',
+ pt: 'Vídeo local carregado: {name}',
+ },
+ loader_ready_log: {
+ context: 'scripts/loader.js:475',
+ it: 'Loader pronto. Seleziona un MP4 e premi Registra volto o Cerca volto.',
+ en: 'Loader ready. Select an MP4 and press Record face or Seek face.',
+ pt: 'Carregador pronto. Selecione um MP4 e pressione Gravar rosto ou Buscar rosto.',
+ notes: 'Glossary: MP4.',
+ },
+ loader_model_error_log: {
+ context: 'scripts/loader.js:481',
+ it: 'Errore modelli loader: {message}',
+ en: 'Loader model error: {message}',
+ pt: 'Erro de modelo do carregador: {message}',
+ },
+ loader_action_error: {
+ context: 'scripts/loader.js:223',
+ it: 'errore',
+ en: 'error',
+ pt: 'erro',
+ },
+ loader_action_record_face: {
+ context: 'scripts/loader.js:231; scripts/loader.js:250',
+ it: 'registra volto',
+ en: 'record face',
+ pt: 'gravar rosto',
+ },
+ loader_action_seek_face: {
+ context: 'scripts/loader.js:286; scripts/loader.js:303',
+ it: 'cerca volto',
+ en: 'seek face',
+ pt: 'buscar rosto',
+ },
+ loader_chip_pressed: {
+ context: 'scripts/loader.js:146',
+ it: 'premuto',
+ en: 'pressed',
+ pt: 'pressionado',
+ },
+ loader_chip_video: {
+ context: 'scripts/loader.js:147',
+ it: 'video',
+ en: 'video',
+ pt: 'vídeo',
+ },
+ loader_no_face_result: {
+ context: 'scripts/loader.js:233; scripts/loader.js:288',
+ it: 'nessun volto rilevato',
+ en: 'no face detected',
+ pt: 'nenhum rosto detectado',
+ },
+ loader_no_2d_face_message: {
+ context: 'scripts/loader.js:234; scripts/loader.js:289',
+ it: 'Nessuna rilevazione 2D face-api era disponibile in questo frame.',
+ en: 'No 2D face-api detection was available at this frame.',
+ pt: 'Nenhuma detecção 2D do face-api estava disponível neste frame.',
+ notes: 'Glossary: 2D, face-api, frame.',
+ },
homepage_wordmark_label: {
context: 'index.html:27',
it: 'Prototipo homepage Ghostmaxxing',
@@ -1751,6 +2467,30 @@ export const messages = {
en: 'Code',
pt: 'Código',
},
+ internal_tools_label: {
+ context: 'index.html',
+ it: 'Strumenti interni',
+ en: 'Internal tools',
+ pt: 'Ferramentas internas',
+ },
+ video_loader_link: {
+ context: 'index.html',
+ it: 'Video Loader',
+ en: 'Video Loader',
+ pt: 'Video Loader',
+ },
+ ghostyle_transfer_link: {
+ context: 'index.html',
+ it: 'Ghostyle Transfer',
+ en: 'Ghostyle Transfer',
+ pt: 'Ghostyle Transfer',
+ },
+ realtime_visualizer_link: {
+ context: 'index.html',
+ it: 'Visualizzatore spazio latente',
+ en: 'Latent Space Visualizer',
+ pt: 'Visualizador de espaço latente',
+ },
homepage_hero_alt: {
context: 'index.html:65',
it: 'Ritratto diviso tra overlay di riconoscimento facciale e camouflage avversario.',
@@ -1844,6 +2584,9 @@ export function applyI18n(root = document) {
root.querySelectorAll('[data-i18n-alt]').forEach((el) => {
el.alt = t(el.dataset.i18nAlt);
});
+ root.querySelectorAll('[data-i18n-content]').forEach((el) => {
+ el.setAttribute('content', t(el.dataset.i18nContent));
+ });
document.documentElement.lang = currentLocale;
}
diff --git a/scripts/lab-ui.js b/scripts/lab-ui.js
index f1353d2..5d238fc 100644
--- a/scripts/lab-ui.js
+++ b/scripts/lab-ui.js
@@ -23,6 +23,7 @@ import { state } from './state.js';
// INTEGRATION: export name confirmed in bbox-overlay.js (setOverlayMode).
import { setOverlayMode } from './bbox-overlay.js';
import { applyI18n, t } from './i18n.js';
+import { initUploadConsentFlow } from './upload-consent.js';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
@@ -131,17 +132,21 @@ on($('#gm-thr-input'), 'input', e => {
});
/* ---- Record -> upload gating -------------------------------------------- */
-// INTEGRATION: we optimistically count a recording per #recordBtn click. If the
-// engine emits an event when a clip is actually saved, listen for that instead.
on($('#recordBtn'), 'click', () => {
if (els.recdot) { els.recdot.classList.add('on'); setTimeout(() => els.recdot.classList.remove('on'), reduce ? 200 : 1600); }
- setTimeout(() => { ui.recordings++; refreshUploadGate(); }, reduce ? 250 : 1700);
});
+if (bus) {
+ bus.addEventListener('uploadQueueChanged', (event) => {
+ ui.recordings = Number(event.detail && event.detail.count) || 0;
+ refreshUploadGate();
+ });
+}
function refreshUploadGate() {
if (els.uploadBadge) { els.uploadBadge.hidden = ui.recordings === 0; els.uploadBadge.textContent = ui.recordings; }
if (els.uploadNav) els.uploadNav.setAttribute('aria-disabled', ui.recordings === 0 ? 'true' : 'false');
if (els.uploadCount) els.uploadCount.textContent = ui.recordings ? t('clips_ready_to_send', { count: ui.recordings }) : t('no_clips_recorded');
}
+initUploadConsentFlow();
/* ---- Pinned Ghostyles on the rail --------------------------------------- */
async function resolvePins() {
diff --git a/scripts/loader.js b/scripts/loader.js
new file mode 100644
index 0000000..0a70bbe
--- /dev/null
+++ b/scripts/loader.js
@@ -0,0 +1,631 @@
+import { state } from './state.js';
+import { loadDb, loadDb3d, renderDbStats } from './db.js';
+import { detectFaceInCam, saveFace, seekFaceInDb } from './engine.js';
+import { cosineSimilarity, getFaceEmbedding, loadMobileNet, saveFace3d } from './engine-3d.js';
+import { MODEL_URLS, DETECTOR_OPTIONS } from './config.js';
+import { els, setStatus, clearOverlay } from './dom.js';
+import { resizeCanvas } from './camera.js';
+import { distance, setLog } from './utils.js';
+import { applyI18n, initI18n, setupLocaleSelect, t } from './i18n.js';
+import { captureThumbnail, saveThumbnail } from './face-thumbnails.js';
+
+const fileInput = document.getElementById('videoFile');
+const seekInput = document.getElementById('videoSeek');
+const currentTimeLabel = document.getElementById('currentTimeLabel');
+const durationLabel = document.getElementById('durationLabel');
+const recordFaceBtn = document.getElementById('recordFaceBtn');
+const seekFaceBtn = document.getElementById('seekFaceBtn');
+const loaderLog = document.getElementById('loaderLog');
+
+let objectUrl = null;
+let entryCounter = 0;
+let modelsReady = false;
+let actionInFlight = false;
+let currentStatus = { kind: 'init', key: 'loader_loading_models_status' };
+
+/**
+ * Runtime bridge used by diagnostic overlays, tests, and loader-adjacent tools.
+ *
+ * @type {object}
+ */
+window.gstmxx = {
+ /**
+ * Writes a message into the shared loader log.
+ *
+ * @param {string} message Message to display.
+ * @param {string} sourcePlugin Source label for the log entry.
+ * @returns {void}
+ */
+ log: (message, sourcePlugin) => setLog(message, sourcePlugin),
+ events: state.gstmxxEvents,
+ /**
+ * Returns a defensive copy of the 2D face archive.
+ *
+ * @returns {object} Cloned 2D face database state.
+ */
+ getDb: () => structuredClone(state.db),
+ /**
+ * Returns a defensive copy of the 3D face archive.
+ *
+ * @returns {object} Cloned 3D face database state.
+ */
+ getDb3d: () => structuredClone(state.db3d),
+ /**
+ * Returns the active 2D effect tracked by shared state.
+ *
+ * @returns {*} Active effect metadata, or null when no effect is active.
+ */
+ getActiveEffect: () => state.activeEffect,
+ /**
+ * Returns the last known 2D detection result.
+ *
+ * @returns {*} Last face-api detection result, or null when unavailable.
+ */
+ getLastResult: () => state.lastKnownEffectResult,
+ /**
+ * Returns the active 2D match threshold.
+ *
+ * @returns {number} 2D descriptor distance threshold.
+ */
+ getMatchThreshold: () => state.MATCH_THRESHOLD,
+ /**
+ * Returns the active 3D match threshold.
+ *
+ * @returns {number} 3D embedding similarity threshold.
+ */
+ getMatchThreshold3d: () => state.MATCH_THRESHOLD_3D,
+ /**
+ * Reports that the standalone loader has no active 3D Ghostyle effect.
+ *
+ * @returns {null} Always null in the MP4 loader.
+ */
+ getActiveEffect3d: () => null,
+ detectorOptions: DETECTOR_OPTIONS,
+ get lastLandmarks3d() { return state.lastLandmarks3d; },
+ set lastLandmarks3d(v) { state.lastLandmarks3d = v; },
+};
+
+/**
+ * Updates the loader status label from a translation key and remembers the
+ * active status so it can be refreshed after a locale change.
+ *
+ * @param {string} kind Status class hint passed to the shared status widget.
+ * @param {string} key Translation key for the status text.
+ * @returns {void}
+ */
+function setLoaderStatus(kind, key) {
+ currentStatus = { kind, key };
+ setStatus(kind, t(key));
+}
+
+/**
+ * Formats a floating-point video timestamp as `mm:ss.mmm`.
+ *
+ * @param {number} seconds Video time in seconds.
+ * @returns {string} Zero-padded display time.
+ */
+function formatVideoTime(seconds) {
+ if (!Number.isFinite(seconds)) return '00:00.000';
+ const minutes = Math.floor(seconds / 60);
+ const rest = seconds - minutes * 60;
+ return `${String(minutes).padStart(2, '0')}:${rest.toFixed(3).padStart(6, '0')}`;
+}
+
+/**
+ * Reads the current video playhead, duration, percentage, and display label.
+ *
+ * @returns {{current: number, duration: number, percent: number, label: string}} Current timeline position.
+ */
+function currentPosition() {
+ const current = els.video.currentTime || 0;
+ const duration = Number.isFinite(els.video.duration) ? els.video.duration : 0;
+ const percent = duration > 0 ? (current / duration) * 100 : 0;
+ return {
+ current,
+ duration,
+ percent,
+ label: `${formatVideoTime(current)} / ${formatVideoTime(duration)} (${percent.toFixed(2)}%)`,
+ };
+}
+
+/**
+ * Enables or disables face actions based on model, video, and lock state.
+ *
+ * @param {boolean} enabled Whether the caller wants actions to be available.
+ * @returns {void}
+ */
+function setActionButtonsEnabled(enabled) {
+ const ready = enabled && modelsReady && els.video.readyState >= 2 && !actionInFlight;
+ recordFaceBtn.disabled = !ready;
+ seekFaceBtn.disabled = !ready;
+}
+
+/**
+ * Synchronizes timeline labels and the seek input with the video element.
+ *
+ * @returns {void}
+ */
+function updateTimelineFromVideo() {
+ const position = currentPosition();
+ currentTimeLabel.textContent = formatVideoTime(position.current);
+ durationLabel.textContent = formatVideoTime(position.duration);
+ if (!seekInput.matches(':active')) seekInput.value = String(position.current);
+}
+
+/**
+ * Resizes all diagnostic overlay canvases to match the active video surface.
+ *
+ * @returns {void}
+ */
+function syncCanvasSize() {
+ resizeCanvas();
+ for (const canvas of [document.getElementById('mesh3dOverlay'), document.getElementById('bboxOverlay')]) {
+ if (!canvas) continue;
+ canvas.width = els.overlay.width;
+ canvas.height = els.overlay.height;
+ }
+}
+
+/**
+ * Captures the current video frame plus diagnostic overlays into a canvas.
+ *
+ * @returns {HTMLCanvasElement} Snapshot canvas used in activity-log entries.
+ */
+function snapshotCanvas() {
+ const canvas = document.createElement('canvas');
+ const width = els.video.videoWidth || els.overlay.width || 1280;
+ const height = els.video.videoHeight || els.overlay.height || 720;
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#050505';
+ ctx.fillRect(0, 0, width, height);
+ try {
+ ctx.drawImage(els.video, 0, 0, width, height);
+ if (els.overlay.width && els.overlay.height) ctx.drawImage(els.overlay, 0, 0, width, height);
+ const bbox = document.getElementById('bboxOverlay');
+ if (bbox?.width && bbox?.height) ctx.drawImage(bbox, 0, 0, width, height);
+ } catch (err) {
+ ctx.fillStyle = '#ffe7a8';
+ ctx.font = '700 24px system-ui, sans-serif';
+ ctx.fillText(err.message || String(err), 24, 48);
+ }
+ return canvas;
+}
+
+/**
+ * Captures and persists the same saved-face preview used by lab.html's history
+ * drawer. The loader already has the successful face-api result, so reuse its
+ * box instead of running a second detection pass.
+ *
+ * @param {number} id Saved face id shared by the 2D and 3D stores.
+ * @param {*} result face-api detection result returned by saveFace().
+ * @returns {Promise
} Whether a thumbnail was saved.
+ */
+async function saveFacePreview(id, result) {
+ const box = result?.detection?.box;
+ if (!box) return false;
+
+ try {
+ const dataUrl = await captureThumbnail(els.video, box);
+ if (!dataUrl) return false;
+ saveThumbnail(id, dataUrl);
+ return true;
+ } catch (err) {
+ setLog(t('thumbnail_capture_failed_log', { message: err.message || String(err) }), 'thumbnails');
+ return false;
+ }
+}
+
+/**
+ * Appends a compact metadata chip to an activity-log container.
+ *
+ * @param {HTMLElement} parent Element that receives the chip.
+ * @param {string} label Chip label.
+ * @param {*} value Chip value rendered as text.
+ * @returns {void}
+ */
+function appendChip(parent, label, value) {
+ const chip = document.createElement('span');
+ chip.className = 'loader-chip';
+ chip.textContent = `${label}: ${value}`;
+ parent.appendChild(chip);
+}
+
+/**
+ * Prepends a diagnostic activity entry with snapshot, summary chips, and JSON details.
+ *
+ * @param {string} kind Human-readable action name.
+ * @param {HTMLCanvasElement} snapshot Canvas snapshot for the entry.
+ * @param {{position: object, metrics: Object.}} summary Position and headline metrics.
+ * @param {*} details Structured details rendered in the entry body.
+ * @returns {void}
+ */
+function appendEntry(kind, snapshot, summary, details) {
+ entryCounter += 1;
+ const entry = document.createElement('article');
+ entry.className = 'loader-entry';
+
+ entry.appendChild(snapshot);
+
+ const body = document.createElement('div');
+ body.className = 'loader-entry__body';
+
+ const title = document.createElement('h3');
+ title.textContent = `#${entryCounter} ${kind}`;
+ body.appendChild(title);
+
+ const meta = document.createElement('div');
+ meta.className = 'loader-entry__meta';
+ appendChip(meta, t('loader_chip_pressed'), new Date().toLocaleString());
+ appendChip(meta, t('loader_chip_video'), summary.position.label);
+ body.appendChild(meta);
+
+ const metrics = document.createElement('div');
+ metrics.className = 'loader-entry__metrics';
+ for (const [label, value] of Object.entries(summary.metrics)) appendChip(metrics, label, value);
+ body.appendChild(metrics);
+
+ const pre = document.createElement('pre');
+ pre.textContent = JSON.stringify(details, null, 2);
+ body.appendChild(pre);
+
+ entry.appendChild(body);
+ loaderLog.prepend(entry);
+}
+
+/**
+ * Extracts stable 2D and 3D face-detection details for logging.
+ *
+ * @param {*} result face-api detection result.
+ * @returns {object} Serializable face summary.
+ */
+function summarizeFace(result) {
+ const landmarks = result?.landmarks;
+ const box = result?.detection?.box;
+ return {
+ detectionScore: result?.detection?.score ?? null,
+ descriptorLength: result?.descriptor?.length ?? 0,
+ landmarks2d: landmarks?.positions?.length ?? landmarks?.getPositions?.()?.length ?? 0,
+ age: Number.isFinite(result?.age) ? Math.round(result.age) : null,
+ gender: result?.gender || null,
+ genderProbability: Number.isFinite(result?.genderProbability) ? result.genderProbability : null,
+ box: box ? {
+ x: Math.round(box.x),
+ y: Math.round(box.y),
+ width: Math.round(box.width),
+ height: Math.round(box.height),
+ } : null,
+ landmarks3d: Array.isArray(state.lastLandmarks3d) ? state.lastLandmarks3d.length : 0,
+ };
+}
+
+/**
+ * Computes sorted descriptor distances between a live 2D result and saved faces.
+ *
+ * @param {*} result face-api detection result containing a descriptor.
+ * @returns {Array} Distances sorted from nearest to farthest.
+ */
+function get2dDistances(result) {
+ const descriptor = result?.descriptor;
+ return (state.db?.faces || []).map((face) => {
+ const faceDistance = distance(descriptor, face.descriptor);
+ return {
+ id: face.id,
+ distance: faceDistance,
+ threshold: state.MATCH_THRESHOLD,
+ match: faceDistance <= state.MATCH_THRESHOLD,
+ };
+ }).sort((a, b) => a.distance - b.distance);
+}
+
+/**
+ * Computes sorted cosine-similarity matches for a live 3D embedding.
+ *
+ * @param {Float32Array|number[]|null} embedding Live MediaPipe embedding.
+ * @returns {Array} Matches sorted from highest to lowest similarity.
+ */
+function get3dDistances(embedding) {
+ if (!embedding) return [];
+ return (state.db3d?.faces || []).map((face) => {
+ const similarity = cosineSimilarity(embedding, face.descriptor3d);
+ return {
+ id: face.id,
+ similarity,
+ distance: 1 - similarity,
+ threshold: state.MATCH_THRESHOLD_3D,
+ match: similarity >= state.MATCH_THRESHOLD_3D,
+ };
+ }).sort((a, b) => b.similarity - a.similarity);
+}
+
+/**
+ * Collapses 2D and 3D detection states into the shared overall match state.
+ *
+ * @param {?object} faceapiSection 2D match section.
+ * @param {?object} mediapipeSection 3D match section.
+ * @returns {string} Overall state used by the readout event bus.
+ */
+function buildOverall(faceapiSection, mediapipeSection) {
+ const f = faceapiSection?.detectionState;
+ const m = mediapipeSection?.detectionState;
+ if (!f || !m) return 'unknown';
+ return f === m ? f : 'partial-elusion';
+}
+
+/**
+ * Runs a face action while preventing concurrent Record/Seek operations.
+ *
+ * @param {Function} fn Action callback to execute under the lock.
+ * @returns {Promise}
+ */
+async function withActionLock(fn) {
+ if (actionInFlight) return;
+ actionInFlight = true;
+ setActionButtonsEnabled(false);
+ try {
+ await fn();
+ } catch (err) {
+ setLoaderStatus('error', 'loader_status_action_failed');
+ appendEntry(t('loader_action_error'), snapshotCanvas(), {
+ position: currentPosition(),
+ metrics: { error: err.message || String(err) },
+ }, { error: err.stack || err.message || String(err) });
+ } finally {
+ actionInFlight = false;
+ setActionButtonsEnabled(true);
+ }
+}
+
+/**
+ * Saves the face visible at the current video frame into the 2D and 3D archives.
+ *
+ * @returns {Promise}
+ */
+async function recordFace() {
+ await withActionLock(async () => {
+ syncCanvasSize();
+ const position = currentPosition();
+ const saved2d = await saveFace();
+ if (!saved2d) {
+ appendEntry(t('loader_action_record_face'), snapshotCanvas(), {
+ position,
+ metrics: { result: t('loader_no_face_result') },
+ }, { message: t('loader_no_2d_face_message') });
+ return;
+ }
+
+ const previewSaved = await saveFacePreview(saved2d.id, saved2d.result);
+ const saved3d = await saveFace3d(saved2d.id);
+ const embedding = saved3d ? (state.db3d.faces.find((face) => face.id === saved2d.id)?.descriptor3d || null) : null;
+ const features = summarizeFace(saved2d.result);
+
+ const faceapiSection = {
+ detectionState: 'matched',
+ distance: 0,
+ matchedId: saved2d.id,
+ liveMinDist: 0,
+ liveMinId: saved2d.id,
+ };
+ const mediapipeSection = saved3d ? {
+ detectionState: 'matched',
+ similarity: saved3d.liveInfo3d?.liveMaxSim ?? 1,
+ matchedId: saved2d.id,
+ liveMaxSim: saved3d.liveInfo3d?.liveMaxSim ?? 1,
+ liveMaxId: saved2d.id,
+ } : null;
+
+ state.gstmxxEvents.dispatchEvent(new CustomEvent('detection', {
+ detail: { result: saved2d.result, activeEffect: null }
+ }));
+ state.gstmxxEvents.dispatchEvent(new CustomEvent('matchStateChanged', {
+ detail: {
+ source: 'save',
+ ghostylePresent: false,
+ faceapi: faceapiSection,
+ mediapipe: mediapipeSection,
+ overall: buildOverall(faceapiSection, mediapipeSection),
+ }
+ }));
+
+ appendEntry(t('loader_action_record_face'), snapshotCanvas(), {
+ position,
+ metrics: {
+ id: saved2d.id,
+ '2D score': features.detectionScore?.toFixed(4) ?? 'n/a',
+ '2D landmarks': features.landmarks2d,
+ '3D vector': embedding ? embedding.length : 'unavailable',
+ preview: previewSaved ? 'saved' : 'unavailable',
+ },
+ }, {
+ action: 'record face',
+ savedId: saved2d.id,
+ previewSaved,
+ video: position,
+ faceapi: features,
+ mediapipe: {
+ saved: !!saved3d,
+ embeddingLength: embedding ? embedding.length : 0,
+ selfSimilarity: saved3d?.liveInfo3d?.liveMaxSim ?? null,
+ },
+ });
+ });
+}
+
+/**
+ * Searches the saved 2D and 3D face archives for the face in the current frame.
+ *
+ * @returns {Promise}
+ */
+async function seekFace() {
+ await withActionLock(async () => {
+ syncCanvasSize();
+ const position = currentPosition();
+ const result = await detectFaceInCam(true);
+ if (!result) {
+ appendEntry(t('loader_action_seek_face'), snapshotCanvas(), {
+ position,
+ metrics: { result: t('loader_no_face_result') },
+ }, { message: t('loader_no_2d_face_message') });
+ return;
+ }
+
+ const liveInfo = seekFaceInDb(result);
+ const embedding = await getFaceEmbedding(els.video).catch(() => null);
+ const distances2d = get2dDistances(result);
+ const distances3d = get3dDistances(embedding);
+ const best3d = distances3d[0] || null;
+
+ const faceapiSection = {
+ detectionState: liveInfo.liveMinDist != null && liveInfo.liveMinDist <= state.MATCH_THRESHOLD ? 'matched' : 'eluded',
+ distance: liveInfo.liveMinDist,
+ matchedId: liveInfo.liveMinDist != null && liveInfo.liveMinDist <= state.MATCH_THRESHOLD ? liveInfo.liveMinId : null,
+ liveMinDist: liveInfo.liveMinDist,
+ liveMinId: liveInfo.liveMinId,
+ };
+ const mediapipeSection = best3d ? {
+ detectionState: best3d.similarity >= state.MATCH_THRESHOLD_3D ? 'matched' : 'eluded',
+ similarity: best3d.similarity,
+ matchedId: best3d.similarity >= state.MATCH_THRESHOLD_3D ? best3d.id : null,
+ liveMaxSim: best3d.similarity,
+ liveMaxId: best3d.id,
+ } : null;
+
+ state.gstmxxEvents.dispatchEvent(new CustomEvent('detection', {
+ detail: { result, activeEffect: null }
+ }));
+ state.gstmxxEvents.dispatchEvent(new CustomEvent('matchStateChanged', {
+ detail: {
+ source: 'find',
+ ghostylePresent: false,
+ faceapi: faceapiSection,
+ mediapipe: mediapipeSection,
+ overall: buildOverall(faceapiSection, mediapipeSection),
+ }
+ }));
+
+ appendEntry(t('loader_action_seek_face'), snapshotCanvas(), {
+ position,
+ metrics: {
+ '2D closest': distances2d[0] ? `ID ${distances2d[0].id} / ${distances2d[0].distance.toFixed(4)}` : 'empty DB',
+ '3D closest': best3d ? `ID ${best3d.id} / ${best3d.similarity.toFixed(4)}` : 'empty DB',
+ 'faces compared': Math.max(distances2d.length, distances3d.length),
+ },
+ }, {
+ action: 'seek face',
+ video: position,
+ features: summarizeFace(result),
+ faceapi: {
+ threshold: state.MATCH_THRESHOLD,
+ nearest: liveInfo,
+ distances: distances2d,
+ },
+ mediapipe: {
+ threshold: state.MATCH_THRESHOLD_3D,
+ embeddingLength: embedding ? embedding.length : 0,
+ distances: distances3d,
+ },
+ });
+ });
+}
+
+/**
+ * Loads face-api model weights and the MediaPipe 3D embedder required by the loader.
+ *
+ * @returns {Promise}
+ */
+async function loadModels() {
+ setLoaderStatus('init', 'loader_status_loading_faceapi');
+ await Promise.all([
+ faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URLS.tiny),
+ faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URLS.landmarks),
+ faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URLS.recognition),
+ faceapi.nets.ageGenderNet.loadFromUri(MODEL_URLS.ageGender),
+ ]);
+
+ setLoaderStatus('init', 'loader_status_loading_3d_embedder');
+ await loadMobileNet();
+}
+
+/**
+ * Wires file input, video timeline, seek, playback, and resize event handlers.
+ *
+ * @returns {void}
+ */
+function bindVideo() {
+ fileInput.addEventListener('change', () => {
+ const file = fileInput.files?.[0];
+ if (!file) return;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ objectUrl = URL.createObjectURL(file);
+ clearOverlay();
+ els.video.srcObject = null;
+ els.video.src = objectUrl;
+ els.video.load();
+ els.placeholder.style.display = 'none';
+ setLog(t('loader_log_loaded_local_video', { name: file.name }), 'loader');
+ });
+
+ els.video.addEventListener('loadedmetadata', () => {
+ seekInput.max = String(Number.isFinite(els.video.duration) ? els.video.duration : 0);
+ seekInput.disabled = false;
+ syncCanvasSize();
+ updateTimelineFromVideo();
+ setActionButtonsEnabled(true);
+ });
+ els.video.addEventListener('loadeddata', () => {
+ syncCanvasSize();
+ setActionButtonsEnabled(true);
+ });
+ els.video.addEventListener('timeupdate', updateTimelineFromVideo);
+ els.video.addEventListener('seeked', () => {
+ syncCanvasSize();
+ updateTimelineFromVideo();
+ });
+ els.video.addEventListener('play', () => setActionButtonsEnabled(true));
+ els.video.addEventListener('pause', () => setActionButtonsEnabled(true));
+
+ seekInput.addEventListener('input', () => {
+ els.video.currentTime = Number(seekInput.value) || 0;
+ updateTimelineFromVideo();
+ });
+
+ window.addEventListener('resize', syncCanvasSize);
+}
+
+/**
+ * Initializes localization, local databases, video bindings, models, and actions.
+ *
+ * @returns {Promise}
+ */
+async function init() {
+ initI18n();
+ setupLocaleSelect(document.getElementById('localeSelect'), () => {
+ applyI18n();
+ setLoaderStatus(currentStatus.kind, currentStatus.key);
+ });
+ applyI18n();
+ state.db = loadDb();
+ state.db3d = loadDb3d();
+ state.isMirrored = false;
+ renderDbStats();
+ bindVideo();
+
+ recordFaceBtn.addEventListener('click', recordFace);
+ seekFaceBtn.addEventListener('click', seekFace);
+
+ try {
+ await loadModels();
+ modelsReady = true;
+ setLoaderStatus('live', 'loader_status_ready');
+ setLog(t('loader_ready_log'), 'loader');
+ state.gstmxxEvents.dispatchEvent(new CustomEvent('ready', { detail: {} }));
+ window.dispatchEvent(new CustomEvent('ghostatiReady'));
+ setActionButtonsEnabled(true);
+ } catch (err) {
+ setLoaderStatus('error', 'loader_status_model_load_failed');
+ setLog(t('loader_model_error_log', { message: err.message || err }), 'loader');
+ }
+}
+
+init();
diff --git a/scripts/realtime.js b/scripts/realtime.js
new file mode 100644
index 0000000..d04c2cc
--- /dev/null
+++ b/scripts/realtime.js
@@ -0,0 +1,503 @@
+import { MODEL_URLS, DETECTOR_OPTIONS } from './config.js';
+
+const APP_STATES = {
+ CALIBRATING: 'CALIBRATING',
+ TRACKING: 'TRACKING',
+};
+
+const DESCRIPTOR_SIZE = 128;
+const CELLS_PER_ROW = 10;
+const MAX_DELTA = 0.15;
+const THRESHOLD = 0.6;
+const GRAPH_MAX_POINTS = 240;
+const GRAPH_UPDATES_PER_SECOND = 3;
+const COUNTDOWN_SECONDS = 3;
+
+const runtime = {
+ appState: APP_STATES.CALIBRATING,
+ baselineDescriptor: null,
+ needBaseline: false,
+ distanceBuffer: [],
+ graphHistory: [],
+ equalizerRows: [],
+ frameRequestId: null,
+ graphTimerId: null,
+ detectorBusy: false,
+ initialized: false,
+};
+
+const els = {
+ video: document.getElementById('webcam'),
+ embeddingBar: document.getElementById('embedding-bar'),
+ graph: document.getElementById('distance-graph'),
+ countdownCircle: document.getElementById('countdown-circle'),
+ btnReset: document.getElementById('btn-reset'),
+ statusPill: document.getElementById('status-pill'),
+ distanceValue: document.getElementById('distance-value'),
+ trackingState: document.getElementById('tracking-state'),
+};
+
+const graphCtx = els.graph ? els.graph.getContext('2d') : null;
+
+/**
+ * Reads graph colors from shared style tokens with sane fallbacks.
+ *
+ * @returns {{background: string, signal: string, threshold: string}} Graph color palette.
+ */
+function getGraphPalette() {
+ const styles = getComputedStyle(document.documentElement);
+ const background = styles.getPropertyValue('--panel-2').trim() || '#2c2318';
+ const signal = styles.getPropertyValue('--dev').trim() || '#7fe3b0';
+ const threshold = styles.getPropertyValue('--net').trim() || '#ff8a4c';
+ return { background, signal, threshold };
+}
+
+/**
+ * Runtime bridge for diagnostics and Playwright checks.
+ *
+ * @type {object}
+ */
+window.gstmxxRealtime = {
+ /**
+ * Returns a clone of current runtime state values for tests and debugging.
+ *
+ * @returns {object} Serializable snapshot of calibrating/tracking internals.
+ */
+ getState() {
+ return {
+ appState: runtime.appState,
+ hasBaseline: Boolean(runtime.baselineDescriptor),
+ needBaseline: runtime.needBaseline,
+ distanceBufferLength: runtime.distanceBuffer.length,
+ graphHistoryLength: runtime.graphHistory.length,
+ };
+ },
+ /**
+ * Forces the calibrating workflow in tests without pressing UI controls.
+ *
+ * @returns {void}
+ */
+ triggerCalibration() {
+ startCalibration();
+ },
+};
+
+/**
+ * Updates the floating status message shown over the video feed.
+ *
+ * @param {string} message Message visible in the status pill.
+ * @returns {void}
+ */
+function setStatus(message) {
+ if (!els.statusPill) return;
+ els.statusPill.textContent = message;
+}
+
+/**
+ * Updates the right-panel state label between CALIBRATING and TRACKING.
+ *
+ * @param {string} state Current app state value.
+ * @returns {void}
+ */
+function setTrackingState(state) {
+ if (!els.trackingState) return;
+ els.trackingState.textContent = state;
+}
+
+/**
+ * Updates the numeric distance readout with a fixed 3-decimal value.
+ *
+ * @param {number} value Euclidean distance value to display.
+ * @returns {void}
+ */
+function setDistanceValue(value) {
+ if (!els.distanceValue) return;
+ els.distanceValue.textContent = Number.isFinite(value) ? value.toFixed(3) : '0.000';
+}
+
+/**
+ * Builds the 128x10 descriptor equalizer grid and caches each row.
+ *
+ * @returns {void}
+ */
+function initEqualizer() {
+ if (!els.embeddingBar) return;
+ els.embeddingBar.innerHTML = '';
+ runtime.equalizerRows = [];
+
+ for (let rowIndex = 0; rowIndex < DESCRIPTOR_SIZE; rowIndex += 1) {
+ const row = document.createElement('div');
+ row.className = 'descriptor-row';
+
+ for (let cellIndex = 0; cellIndex < CELLS_PER_ROW; cellIndex += 1) {
+ const cell = document.createElement('div');
+ cell.className = 'cell';
+ row.appendChild(cell);
+ }
+
+ els.embeddingBar.appendChild(row);
+ runtime.equalizerRows.push(row);
+ }
+}
+
+/**
+ * Draws the smoothed distance history with time on the vertical axis.
+ * Old samples are at the top and newest samples at the bottom.
+ *
+ * @returns {void}
+ */
+function drawGraphCanvas() {
+ if (!graphCtx || !els.graph) return;
+
+ const palette = getGraphPalette();
+
+ const width = els.graph.width;
+ const height = els.graph.height;
+
+ graphCtx.clearRect(0, 0, width, height);
+
+ graphCtx.fillStyle = palette.background;
+ graphCtx.fillRect(0, 0, width, height);
+
+ const thresholdX = Math.min((THRESHOLD / 1.0) * width, width);
+ graphCtx.strokeStyle = palette.threshold;
+ graphCtx.lineWidth = 2;
+ graphCtx.beginPath();
+ graphCtx.moveTo(thresholdX, 0);
+ graphCtx.lineTo(thresholdX, height);
+ graphCtx.stroke();
+
+ if (!runtime.graphHistory.length) return;
+
+ const points = runtime.graphHistory;
+ const maxVisible = Math.min(points.length, GRAPH_MAX_POINTS);
+ const start = points.length - maxVisible;
+ const span = Math.max(maxVisible - 1, 1);
+
+ graphCtx.strokeStyle = palette.signal;
+ graphCtx.lineWidth = 2;
+ graphCtx.beginPath();
+
+ for (let i = 0; i < maxVisible; i += 1) {
+ const value = points[start + i];
+ const normalized = Math.max(0, Math.min(1, value / 1.0));
+ const x = normalized * width;
+ const y = (i / span) * height;
+
+ if (i === 0) graphCtx.moveTo(x, y);
+ else graphCtx.lineTo(x, y);
+ }
+
+ graphCtx.stroke();
+}
+
+/**
+ * Updates every descriptor row based on per-index absolute baseline deltas.
+ *
+ * @param {Float32Array|number[]} baseline Baseline descriptor captured during calibration.
+ * @param {Float32Array|number[]} current Current live descriptor.
+ * @returns {void}
+ */
+function updateEqualizer(baseline, current) {
+ if (!runtime.equalizerRows.length) return;
+
+ for (let i = 0; i < DESCRIPTOR_SIZE; i += 1) {
+ const delta = Math.abs(baseline[i] - current[i]);
+ const scaled = Math.floor((delta / MAX_DELTA) * CELLS_PER_ROW);
+ const litCells = Math.max(0, Math.min(CELLS_PER_ROW, scaled));
+ const cells = runtime.equalizerRows[i].children;
+
+ for (let c = 0; c < CELLS_PER_ROW; c += 1) {
+ const cell = cells[c];
+ cell.className = 'cell';
+
+ if (c >= litCells) continue;
+ if (c < 3) cell.classList.add('on-low');
+ else if (c < 7) cell.classList.add('on-med');
+ else cell.classList.add('on-high');
+ }
+ }
+}
+
+/**
+ * Resets the equalizer UI to its neutral unlit state.
+ *
+ * @returns {void}
+ */
+function resetEqualizer() {
+ for (const row of runtime.equalizerRows) {
+ for (const cell of row.children) cell.className = 'cell';
+ }
+}
+
+/**
+ * Resizes the graph canvas to match CSS pixels and device pixel ratio.
+ *
+ * @returns {void}
+ */
+function resizeGraphCanvas() {
+ if (!els.graph) return;
+ const rect = els.graph.getBoundingClientRect();
+ const dpr = Math.max(window.devicePixelRatio || 1, 1);
+ const width = Math.max(Math.floor(rect.width * dpr), 1);
+ const height = Math.max(Math.floor(rect.height * dpr), 1);
+
+ if (els.graph.width === width && els.graph.height === height) return;
+ els.graph.width = width;
+ els.graph.height = height;
+ drawGraphCanvas();
+}
+
+/**
+ * Captures a baseline descriptor after the countdown by waiting for the first valid face.
+ *
+ * @returns {void}
+ */
+function armSafeBaselineCapture() {
+ runtime.needBaseline = true;
+ setStatus('Waiting for a clear face to capture baseline...');
+ if (!els.countdownCircle) return;
+ els.countdownCircle.style.display = 'flex';
+ els.countdownCircle.classList.add('waiting');
+ els.countdownCircle.textContent = 'Face not detected. Step into frame.';
+}
+
+/**
+ * Runs the 3-2-1 countdown then enables safe baseline capture mode.
+ *
+ * @returns {void}
+ */
+function startCalibration() {
+ runtime.appState = APP_STATES.CALIBRATING;
+ runtime.baselineDescriptor = null;
+ runtime.needBaseline = false;
+ runtime.distanceBuffer = [];
+ runtime.graphHistory = [];
+ setTrackingState(APP_STATES.CALIBRATING);
+ setDistanceValue(0);
+ resetEqualizer();
+ drawGraphCanvas();
+
+ if (els.btnReset) els.btnReset.style.display = 'none';
+ if (!els.countdownCircle) {
+ armSafeBaselineCapture();
+ return;
+ }
+
+ let remaining = COUNTDOWN_SECONDS;
+ els.countdownCircle.classList.remove('waiting');
+ els.countdownCircle.style.display = 'flex';
+ els.countdownCircle.textContent = String(remaining);
+ setStatus('Calibration countdown started...');
+
+ const interval = window.setInterval(() => {
+ remaining -= 1;
+ if (remaining > 0) {
+ els.countdownCircle.textContent = String(remaining);
+ return;
+ }
+ window.clearInterval(interval);
+ armSafeBaselineCapture();
+ }, 1000);
+}
+
+/**
+ * Updates app state after a valid baseline descriptor is captured.
+ *
+ * @param {Float32Array|number[]} descriptor Descriptor captured from the live frame.
+ * @returns {void}
+ */
+function setBaseline(descriptor) {
+ runtime.baselineDescriptor = Array.from(descriptor);
+ runtime.needBaseline = false;
+ runtime.appState = APP_STATES.TRACKING;
+ runtime.distanceBuffer = [];
+ runtime.graphHistory = [];
+ setTrackingState(APP_STATES.TRACKING);
+ setDistanceValue(0);
+ drawGraphCanvas();
+ setStatus('Tracking active. Move your face to inspect descriptor drift.');
+
+ if (els.countdownCircle) {
+ els.countdownCircle.style.display = 'none';
+ els.countdownCircle.classList.remove('waiting');
+ }
+ if (els.btnReset) els.btnReset.style.display = 'inline-flex';
+}
+
+/**
+ * Handles one detected face based on the current runtime state.
+ *
+ * @param {*} detection face-api detection result containing a descriptor.
+ * @returns {void}
+ */
+function processDetection(detection) {
+ const currentDescriptor = detection.descriptor;
+ if (!currentDescriptor) return;
+
+ if (runtime.appState === APP_STATES.CALIBRATING && runtime.needBaseline) {
+ setBaseline(currentDescriptor);
+ return;
+ }
+
+ if (runtime.appState !== APP_STATES.TRACKING || !runtime.baselineDescriptor) return;
+
+ const totalDistance = faceapi.euclideanDistance(runtime.baselineDescriptor, currentDescriptor);
+ runtime.distanceBuffer.push(totalDistance);
+ setDistanceValue(totalDistance);
+ updateEqualizer(runtime.baselineDescriptor, currentDescriptor);
+
+ if (totalDistance > THRESHOLD) setStatus('Distance crossed threshold (0.6): likely non-match');
+ else setStatus('Within threshold: likely match');
+}
+
+/**
+ * Handles "no face detected" situations depending on calibrating/tracking state.
+ *
+ * @returns {void}
+ */
+function processNoFace() {
+ if (runtime.appState === APP_STATES.CALIBRATING && runtime.needBaseline && els.countdownCircle) {
+ els.countdownCircle.style.display = 'flex';
+ els.countdownCircle.classList.add('waiting');
+ els.countdownCircle.textContent = 'Face not detected. Step into frame.';
+ }
+
+ if (runtime.appState === APP_STATES.TRACKING) {
+ setStatus('Tracking paused: no face detected.');
+ }
+}
+
+/**
+ * Executes one face-api detection pass and schedules the next animation frame.
+ *
+ * @returns {Promise}
+ */
+async function onFrame() {
+ if (!runtime.initialized) return;
+ runtime.frameRequestId = window.requestAnimationFrame(onFrame);
+ if (runtime.detectorBusy) return;
+ if (!els.video || els.video.readyState < 2) return;
+
+ runtime.detectorBusy = true;
+ try {
+ const detection = await faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS)
+ .withFaceLandmarks()
+ .withFaceDescriptor();
+
+ if (detection) processDetection(detection);
+ else processNoFace();
+ } catch (err) {
+ console.error('realtime detection loop error:', err);
+ setStatus('Detection error. Check console.');
+ } finally {
+ runtime.detectorBusy = false;
+ }
+}
+
+/**
+ * Pulls buffered distance samples, computes an average, and updates graph history.
+ *
+ * @returns {void}
+ */
+function flushDistanceBuffer() {
+ if (!runtime.distanceBuffer.length) return;
+ const sum = runtime.distanceBuffer.reduce((acc, value) => acc + value, 0);
+ const averageDistance = sum / runtime.distanceBuffer.length;
+ runtime.distanceBuffer = [];
+
+ runtime.graphHistory.push(averageDistance);
+ if (runtime.graphHistory.length > GRAPH_MAX_POINTS) runtime.graphHistory.shift();
+ drawGraphCanvas();
+}
+
+/**
+ * Starts the low-frequency graph smoothing loop at 3 updates per second.
+ *
+ * @returns {void}
+ */
+function startGraphLoop() {
+ if (runtime.graphTimerId) window.clearInterval(runtime.graphTimerId);
+ runtime.graphTimerId = window.setInterval(flushDistanceBuffer, 1000 / GRAPH_UPDATES_PER_SECOND);
+}
+
+/**
+ * Requests camera stream and binds it to the realtime video element.
+ *
+ * @returns {Promise}
+ */
+async function initWebcam() {
+ if (!els.video) throw new Error('Missing #webcam element');
+
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { facingMode: 'user' },
+ audio: false,
+ });
+ els.video.srcObject = stream;
+ await els.video.play();
+ setStatus('Webcam ready. Press "Set Baseline" to start calibration.');
+}
+
+/**
+ * Loads the face-api models required for descriptor extraction.
+ *
+ * @returns {Promise}
+ */
+async function loadModels() {
+ const modelUris = [MODEL_URLS.tiny, MODEL_URLS.landmarks, MODEL_URLS.recognition];
+ await Promise.all([
+ faceapi.nets.tinyFaceDetector.loadFromUri(modelUris[0]),
+ faceapi.nets.faceLandmark68Net.loadFromUri(modelUris[1]),
+ faceapi.nets.faceRecognitionNet.loadFromUri(modelUris[2]),
+ ]);
+ setStatus('Models loaded. Preparing camera...');
+}
+
+/**
+ * Binds interaction and resize listeners needed during runtime.
+ *
+ * @returns {void}
+ */
+function bindListeners() {
+ if (els.btnReset) els.btnReset.addEventListener('click', startCalibration);
+ window.addEventListener('resize', resizeGraphCanvas);
+}
+
+/**
+ * Initializes realtime visualizer components and starts analysis loops.
+ *
+ * @returns {Promise}
+ */
+export async function initRealtime() {
+ if (runtime.initialized) return;
+ runtime.initialized = true;
+ setStatus('Loading models...');
+ setTrackingState(APP_STATES.CALIBRATING);
+
+ initEqualizer();
+ bindListeners();
+ resizeGraphCanvas();
+ drawGraphCanvas();
+
+ await loadModels();
+ await initWebcam();
+
+ startGraphLoop();
+ onFrame();
+}
+
+/**
+ * Bootstraps realtime visualizer and reports startup errors in the status pill.
+ *
+ * @returns {void}
+ */
+async function boot() {
+ try {
+ await initRealtime();
+ } catch (err) {
+ console.error('Failed to initialize realtime visualizer:', err);
+ setStatus(`Initialization failed: ${err.message || String(err)}`);
+ }
+}
+
+boot();
\ No newline at end of file
diff --git a/scripts/transfer.js b/scripts/transfer.js
new file mode 100644
index 0000000..843aa40
--- /dev/null
+++ b/scripts/transfer.js
@@ -0,0 +1,968 @@
+/**
+ * @module transfer
+ * @description
+ * # Ghostyle Transfer Lab
+ *
+ * ## Project Objective
+ *
+ * The goal of this browser-only experiment is to let a user capture a painted
+ * visual style from one face and preview it on another face without sending
+ * images to a server. Instead of treating the transfer as a black box, the page
+ * exposes the practical stages of the process: choosing source and target
+ * images, marking face regions, extracting a transparent paint matte, previewing
+ * that matte, and compositing the result with adjustable blend controls.
+ *
+ * ## Technical Stack
+ *
+ * The module is written in JavaScript and uses the DOM for the control surface,
+ * the Canvas 2D API for pixel extraction, matte generation, preview drawing,
+ * affine triangle warping, and final compositing, and `@vladmandic/face-api`
+ * for optional 68-point landmark detection. All image processing happens in the
+ * browser runtime.
+ *
+ * ## Interface Model
+ *
+ * The UI is organized around three upload slots: an optional before image, a
+ * required painted after image, and a required target image. Each slot renders a
+ * fitted preview canvas where the user draws a face box in image coordinates.
+ * A side matte preview shows the extracted paint layer, while the result panel
+ * displays the final target composite and enables download once a transfer has
+ * completed.
+ *
+ * ## Functional Workflow
+ *
+ * The transfer starts in a setup state until the after and target slots both
+ * have images and face boxes. Running the transfer normalizes the after face
+ * crop into a canonical square, subtracts either the matching before crop or a
+ * sampled reference color, feathers the resulting alpha plane, and then places
+ * the matte onto the target. When landmarks are available and mesh mode is
+ * enabled, corresponding face points are triangulated and the matte is warped
+ * triangle by triangle; otherwise the system falls back to direct box placement
+ * with a status note that explains which mode was used.
+ */
+import { applyI18n, initI18n, setupLocaleSelect, t } from './i18n.js';
+
+const CANON = 512;
+const RESULT_MAX = 900;
+const MODEL_ROOT = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/';
+
+/**
+ * Two-dimensional point in image or canvas coordinates.
+ *
+ * @typedef {Object} Point
+ * @property {number} x - Horizontal coordinate.
+ * @property {number} y - Vertical coordinate.
+ *
+ * Rectangular face selection in original image coordinates.
+ *
+ * @typedef {Object} Box
+ * @property {number} x - Left edge.
+ * @property {number} y - Top edge.
+ * @property {number} w - Width.
+ * @property {number} h - Height.
+ *
+ * State tracked for one upload slot and its preview canvas.
+ *
+ * @typedef {Object} TransferSlot
+ * @property {?HTMLImageElement} img - Loaded source image, or null before load.
+ * @property {?Box} box - User-selected face box, or null until selected.
+ * @property {number} scale - Ratio from preview pixels to original image pixels.
+ * @property {number} dispW - Preview image width in CSS pixels.
+ * @property {number} dispH - Preview image height in CSS pixels.
+ *
+ * Three-channel average color stored as red, green, and blue values.
+ *
+ * @typedef {Array.} RgbTuple
+ */
+
+/** @type {Record<'before'|'after'|'target', TransferSlot>} */
+const slots = {
+ before: { img: null, box: null, scale: 1, dispW: 0, dispH: 0 },
+ after: { img: null, box: null, scale: 1, dispW: 0, dispH: 0 },
+ target: { img: null, box: null, scale: 1, dispW: 0, dispH: 0 }
+};
+
+let faceapiReady = false;
+let lastResultCanvas = null;
+let engineState = { key: 'transfer_engine_loading', ready: false };
+let messageState = { key: 'transfer_msg_load_after_target', params: {} };
+let modeState = { key: 'transfer_mode_not_run', params: {}, placed: false };
+
+/**
+ * Resolve a required DOM element by id.
+ *
+ * @param {string} id - DOM id.
+ * @returns {HTMLElement} Matching element.
+ * @throws {Error} When the expected element is missing from the page.
+ */
+function byId(id) {
+ const el = document.getElementById(id);
+ if (!el) throw new Error(`Missing transfer element: ${id}`);
+ return el;
+}
+
+/**
+ * Resolve a canvas rendering context with a clear failure mode.
+ *
+ * @param {HTMLCanvasElement} canvas - Canvas to draw into.
+ * @returns {CanvasRenderingContext2D} Two-dimensional context.
+ * @throws {Error} When the browser cannot allocate a 2D context.
+ */
+function ctx2d(canvas) {
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error(t('canvas_2d_context_error'));
+ return ctx;
+}
+
+/**
+ * Clamp a numeric value to a closed interval.
+ *
+ * @param {number} value - Candidate value.
+ * @param {number} min - Inclusive lower bound.
+ * @param {number} max - Inclusive upper bound.
+ * @returns {number} Clamped value.
+ */
+function clamp(value, min, max) {
+ return value < min ? min : (value > max ? max : value);
+}
+
+/**
+ * Change the currently displayed status message.
+ *
+ * @param {string} key - Translation key.
+ * @param {Record} [params] - Optional interpolation values.
+ * @returns {void}
+ */
+function setMessage(key, params = {}) {
+ messageState = { key, params };
+ byId('msg').textContent = t(key, params);
+}
+
+/**
+ * Change the engine availability badge.
+ *
+ * @param {string} key - Translation key for the badge text.
+ * @param {boolean} ready - Whether to render the badge as ready.
+ * @returns {void}
+ */
+function setEngineState(key, ready) {
+ engineState = { key, ready };
+ byId('engineText').textContent = t(key);
+ byId('engineBadge').classList.toggle('is-ready', ready);
+}
+
+/**
+ * Render the placement mode note. Wrapped modes read as
+ * "Placed using: {mode}", while setup states are shown directly.
+ *
+ * @returns {void}
+ */
+function renderModeNote() {
+ const modeText = t(modeState.key, modeState.params);
+ byId('modeNote').textContent = modeState.placed
+ ? t('transfer_mode_placed', { mode: modeText })
+ : modeText;
+}
+
+/**
+ * Change the placement mode note.
+ *
+ * @param {string} key - Translation key for the mode.
+ * @param {Record} [params] - Optional interpolation values.
+ * @param {boolean} [placed=false] - Whether to prefix with the placement wrapper.
+ * @returns {void}
+ */
+function setModeState(key, params = {}, placed = false) {
+ modeState = { key, params, placed };
+ renderModeNote();
+}
+
+/**
+ * Re-render the empty result placeholder.
+ *
+ * @returns {void}
+ */
+function renderResultEmpty() {
+ const holder = byId('result');
+ holder.innerHTML = '';
+ const empty = document.createElement('span');
+ empty.className = 'tool-result__empty';
+ empty.dataset.i18n = 'transfer_result_empty';
+ empty.textContent = t('transfer_result_empty');
+ holder.appendChild(empty);
+}
+
+/**
+ * Re-apply locale-dependent runtime text that is not covered by static
+ * `data-i18n` attributes.
+ *
+ * @returns {void}
+ */
+function refreshLocalizedState() {
+ document.title = t('transfer_page_title');
+ byId('engineText').textContent = t(engineState.key);
+ byId('engineBadge').classList.toggle('is-ready', engineState.ready);
+ byId('msg').textContent = t(messageState.key, messageState.params);
+ renderModeNote();
+ for (const name of Object.keys(slots)) refreshSlotNote(name);
+ if (!lastResultCanvas) renderResultEmpty();
+}
+
+/**
+ * Load the optional landmark models. The transfer remains usable in box mode
+ * when the CDN or model download fails.
+ *
+ * @returns {Promise} Resolves after the best-effort setup.
+ */
+async function initFaceapi() {
+ const api = window.faceapi;
+ if (!api) {
+ setEngineState('transfer_engine_unavailable', false);
+ return;
+ }
+
+ try {
+ await api.nets.tinyFaceDetector.loadFromUri(MODEL_ROOT);
+ await api.nets.faceLandmark68Net.loadFromUri(MODEL_ROOT);
+ faceapiReady = true;
+ setEngineState('transfer_engine_ready', true);
+ } catch (err) {
+ setEngineState('transfer_engine_failed', false);
+ console.warn('face-api load failed:', err);
+ }
+}
+
+/**
+ * Fit a source image into its square preview stage and bind box drawing to the
+ * generated canvas.
+ *
+ * @param {'before'|'after'|'target'} name - Slot to render.
+ * @returns {void}
+ */
+function fitPreview(name) {
+ const slot = slots[name];
+ const stage = byId(`stage-${name}`);
+ stage.querySelectorAll('canvas').forEach((canvas) => canvas.remove());
+ const hint = byId(`hint-${name}`);
+ hint.style.display = slot.img ? 'none' : '';
+ if (!slot.img) return;
+
+ const rect = stage.getBoundingClientRect();
+ const stageWidth = rect.width;
+ const stageHeight = rect.height;
+ const aspectRatio = slot.img.width / slot.img.height;
+ let displayWidth = stageWidth;
+ let displayHeight = stageWidth / aspectRatio;
+
+ if (displayHeight > stageHeight) {
+ displayHeight = stageHeight;
+ displayWidth = stageHeight * aspectRatio;
+ }
+
+ slot.dispW = displayWidth;
+ slot.dispH = displayHeight;
+ slot.scale = slot.img.width / displayWidth;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = Math.round(displayWidth);
+ canvas.height = Math.round(displayHeight);
+ canvas.style.width = `${displayWidth}px`;
+ canvas.style.height = `${displayHeight}px`;
+ canvas.style.left = `${(stageWidth - displayWidth) / 2}px`;
+ canvas.style.top = `${(stageHeight - displayHeight) / 2}px`;
+ canvas.style.inset = 'auto';
+ stage.appendChild(canvas);
+ redrawSlot(name, canvas);
+ attachBoxDrawing(name, canvas);
+}
+
+/**
+ * Draw a slot image and the current crop box into its preview canvas.
+ *
+ * @param {'before'|'after'|'target'} name - Slot to draw.
+ * @param {HTMLCanvasElement} canvas - Preview canvas.
+ * @returns {void}
+ */
+function redrawSlot(name, canvas) {
+ const slot = slots[name];
+ if (!slot.img) return;
+
+ const ctx = ctx2d(canvas);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(slot.img, 0, 0, canvas.width, canvas.height);
+
+ if (!slot.box) return;
+
+ const box = slot.box;
+ const scale = 1 / slot.scale;
+ const x = box.x * scale;
+ const y = box.y * scale;
+ const width = box.w * scale;
+ const height = box.h * scale;
+ const tick = 10;
+
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = '#f3c747';
+ ctx.strokeRect(x, y, width, height);
+ ctx.strokeStyle = '#ffe7a8';
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(x, y + tick); ctx.lineTo(x, y); ctx.lineTo(x + tick, y);
+ ctx.moveTo(x + width - tick, y); ctx.lineTo(x + width, y); ctx.lineTo(x + width, y + tick);
+ ctx.moveTo(x, y + height - tick); ctx.lineTo(x, y + height); ctx.lineTo(x + tick, y + height);
+ ctx.moveTo(x + width - tick, y + height); ctx.lineTo(x + width, y + height); ctx.lineTo(x + width, y + height - tick);
+ ctx.stroke();
+}
+
+/**
+ * Attach pointer handlers that let the user draw a face box in image
+ * coordinates while seeing the box in preview coordinates.
+ *
+ * @param {'before'|'after'|'target'} name - Slot being edited.
+ * @param {HTMLCanvasElement} canvas - Interactive preview canvas.
+ * @returns {void}
+ */
+function attachBoxDrawing(name, canvas) {
+ const slot = slots[name];
+ let drawing = false;
+ let startX = 0;
+ let startY = 0;
+
+ const toImagePoint = (event) => {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: (event.clientX - rect.left) * slot.scale,
+ y: (event.clientY - rect.top) * slot.scale
+ };
+ };
+
+ canvas.addEventListener('pointerdown', (event) => {
+ drawing = true;
+ canvas.setPointerCapture(event.pointerId);
+ const point = toImagePoint(event);
+ startX = point.x;
+ startY = point.y;
+ slot.box = { x: startX, y: startY, w: 0, h: 0 };
+ });
+
+ canvas.addEventListener('pointermove', (event) => {
+ if (!drawing) return;
+ const point = toImagePoint(event);
+ slot.box = {
+ x: Math.min(startX, point.x),
+ y: Math.min(startY, point.y),
+ w: Math.abs(point.x - startX),
+ h: Math.abs(point.y - startY)
+ };
+ redrawSlot(name, canvas);
+ });
+
+ const endDrawing = () => {
+ if (!drawing) return;
+ drawing = false;
+ if (slot.box && (slot.box.w < 8 || slot.box.h < 8)) slot.box = null;
+ redrawSlot(name, canvas);
+ refreshState();
+ };
+
+ canvas.addEventListener('pointerup', endDrawing);
+ canvas.addEventListener('pointercancel', endDrawing);
+}
+
+/**
+ * Update a single slot note from its current image and box state.
+ *
+ * @param {string} name - Slot name.
+ * @returns {void}
+ */
+function refreshSlotNote(name) {
+ const slot = slots[name];
+ const note = byId(`note-${name}`);
+ if (slot.box) {
+ note.textContent = t('transfer_note_box_set');
+ } else if (slot.img) {
+ note.textContent = t('transfer_note_draw_box');
+ } else {
+ note.textContent = '';
+ }
+}
+
+/**
+ * Recompute readiness and related user-facing prompts after image or box
+ * changes.
+ *
+ * @returns {void}
+ */
+function refreshState() {
+ for (const name of Object.keys(slots)) refreshSlotNote(name);
+ const ready = Boolean(slots.after.img && slots.after.box && slots.target.img && slots.target.box);
+ byId('run').disabled = !ready;
+ setMessage(ready ? 'transfer_msg_ready' : 'transfer_msg_load_after_target');
+}
+
+/**
+ * Load an image file selected or dropped by the user.
+ *
+ * @param {'before'|'after'|'target'} name - Destination slot.
+ * @param {File} file - Browser file object.
+ * @returns {Promise} Loaded image element.
+ */
+function loadFile(name, file) {
+ return loadURL(name, URL.createObjectURL(file));
+}
+
+/**
+ * Load an image URL into a slot. Exposed through `window.GT` for automated
+ * checks and demos.
+ *
+ * @param {'before'|'after'|'target'} name - Destination slot.
+ * @param {string} url - Image URL.
+ * @returns {Promise} Loaded image element.
+ */
+function loadURL(name, url) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.crossOrigin = 'anonymous';
+ image.onload = () => {
+ slots[name].img = image;
+ slots[name].box = null;
+ fitPreview(name);
+ refreshState();
+ resolve(image);
+ };
+ image.onerror = reject;
+ image.src = url;
+ });
+}
+
+/**
+ * Draw a slot crop into the canonical transfer coordinate space.
+ *
+ * @param {'before'|'after'|'target'} name - Slot to crop.
+ * @returns {HTMLCanvasElement} Canonical face-frame canvas.
+ */
+function boxToCanon(name) {
+ const slot = slots[name];
+ if (!slot.img || !slot.box) throw new Error(`Slot is missing image or box: ${name}`);
+
+ const canvas = document.createElement('canvas');
+ canvas.width = CANON;
+ canvas.height = CANON;
+ ctx2d(canvas).drawImage(slot.img, slot.box.x, slot.box.y, slot.box.w, slot.box.h, 0, 0, CANON, CANON);
+ return canvas;
+}
+
+/**
+ * Extract the painted pixels as an RGBA matte in canonical face coordinates.
+ * With a before image, RGB distance is measured against that before crop. When
+ * no before is present, a coarse median color approximation is used instead.
+ *
+ * @param {number} threshold - RGB distance threshold for paint extraction.
+ * @param {number} feather - Blur radius for softening the matte alpha.
+ * @returns {HTMLCanvasElement} Canonical paint matte.
+ */
+function extractMatte(threshold, feather) {
+ const afterCanon = boxToCanon('after');
+ const afterData = ctx2d(afterCanon).getImageData(0, 0, CANON, CANON);
+ const refData = slots.before.img && slots.before.box
+ ? ctx2d(boxToCanon('before')).getImageData(0, 0, CANON, CANON)
+ : null;
+ const median = refData ? null : medianColor(afterData);
+ const count = CANON * CANON;
+ const output = new ImageData(CANON, CANON);
+ const alpha = new Float32Array(count);
+
+ for (let i = 0; i < count; i += 1) {
+ const p = i * 4;
+ const ar = afterData.data[p];
+ const ag = afterData.data[p + 1];
+ const ab = afterData.data[p + 2];
+ const rr = refData ? refData.data[p] : median[0];
+ const rg = refData ? refData.data[p + 1] : median[1];
+ const rb = refData ? refData.data[p + 2] : median[2];
+ const distance = Math.sqrt((ar - rr) ** 2 + (ag - rg) ** 2 + (ab - rb) ** 2);
+ alpha[i] = clamp((distance - threshold) / threshold, 0, 1);
+ output.data[p] = ar;
+ output.data[p + 1] = ag;
+ output.data[p + 2] = ab;
+ }
+
+ if (feather > 0) boxBlur(alpha, CANON, CANON, feather);
+
+ for (let i = 0; i < count; i += 1) {
+ output.data[i * 4 + 3] = Math.round(alpha[i] * 255);
+ }
+
+ const matte = document.createElement('canvas');
+ matte.width = CANON;
+ matte.height = CANON;
+ ctx2d(matte).putImageData(output, 0, 0);
+ return matte;
+}
+
+/**
+ * Approximate the dominant skin/reference color by sparse sampling.
+ *
+ * @param {ImageData} imageData - Canonical source image data.
+ * @returns {RgbTuple} Average RGB tuple.
+ */
+function medianColor(imageData) {
+ let r = 0;
+ let g = 0;
+ let b = 0;
+ let n = 0;
+ const data = imageData.data;
+
+ for (let i = 0; i < data.length; i += 4 * 29) {
+ r += data[i];
+ g += data[i + 1];
+ b += data[i + 2];
+ n += 1;
+ }
+
+ return [r / n, g / n, b / n];
+}
+
+/**
+ * Apply two-pass box blur to a float alpha plane.
+ *
+ * @param {Float32Array} alpha - Alpha plane, mutated in place.
+ * @param {number} width - Plane width.
+ * @param {number} height - Plane height.
+ * @param {number} radius - Blur radius.
+ * @returns {void}
+ */
+function boxBlur(alpha, width, height, radius) {
+ const blurRadius = Math.round(radius);
+ const tmp = new Float32Array(alpha.length);
+
+ for (let pass = 0; pass < 2; pass += 1) {
+ for (let y = 0; y < height; y += 1) {
+ let sum = 0;
+ const row = y * width;
+ for (let x = -blurRadius; x <= blurRadius; x += 1) sum += alpha[row + clamp(x, 0, width - 1)];
+ for (let x = 0; x < width; x += 1) {
+ tmp[row + x] = sum / (2 * blurRadius + 1);
+ sum += alpha[row + clamp(x + blurRadius + 1, 0, width - 1)] - alpha[row + clamp(x - blurRadius, 0, width - 1)];
+ }
+ }
+
+ for (let x = 0; x < width; x += 1) {
+ let sum = 0;
+ for (let y = -blurRadius; y <= blurRadius; y += 1) sum += tmp[clamp(y, 0, height - 1) * width + x];
+ for (let y = 0; y < height; y += 1) {
+ alpha[y * width + x] = sum / (2 * blurRadius + 1);
+ sum += tmp[clamp(y + blurRadius + 1, 0, height - 1) * width + x] - tmp[clamp(y - blurRadius, 0, height - 1) * width + x];
+ }
+ }
+ }
+}
+
+/**
+ * Detect 68 face landmarks inside a user-provided slot box, mapping the result
+ * back into original image coordinates.
+ *
+ * @param {'after'|'target'} name - Slot to analyze.
+ * @returns {Promise} Landmark points, or null when detection fails.
+ */
+async function detectLandmarks(name) {
+ const api = window.faceapi;
+ if (!faceapiReady || !api) return null;
+
+ const slot = slots[name];
+ if (!slot.img || !slot.box) return null;
+
+ const margin = 0.25;
+ const cropX = slot.box.x - slot.box.w * margin;
+ const cropY = slot.box.y - slot.box.h * margin;
+ const cropWidth = slot.box.w * (1 + 2 * margin);
+ const cropHeight = slot.box.h * (1 + 2 * margin);
+ const crop = document.createElement('canvas');
+ crop.width = Math.round(cropWidth);
+ crop.height = Math.round(cropHeight);
+ ctx2d(crop).drawImage(slot.img, cropX, cropY, cropWidth, cropHeight, 0, 0, crop.width, crop.height);
+
+ const detection = await api
+ .detectSingleFace(crop, new api.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.2 }))
+ .withFaceLandmarks();
+
+ if (!detection) return null;
+ return detection.landmarks.positions.map((point) => ({ x: cropX + point.x, y: cropY + point.y }));
+}
+
+/**
+ * Convert image-coordinate landmarks to canonical face-frame coordinates.
+ *
+ * @param {Point[]} points - Source landmarks.
+ * @param {Box} box - Face box used as canonical frame.
+ * @returns {Point[]} Canonical points.
+ */
+function landmarksToCanon(points, box) {
+ return points.map((point) => ({
+ x: ((point.x - box.x) / box.w) * CANON,
+ y: ((point.y - box.y) / box.h) * CANON
+ }));
+}
+
+/**
+ * Build a Delaunay-like triangulation with Bowyer-Watson. This keeps the mesh
+ * dependency-free for a small 68-point landmark set.
+ *
+ * @param {Point[]} points - Points to triangulate.
+ * @returns {number[][]} Triples of point indices.
+ */
+function triangulate(points) {
+ const count = points.length;
+ if (count < 3) return [];
+
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const point of points) {
+ minX = Math.min(minX, point.x);
+ minY = Math.min(minY, point.y);
+ maxX = Math.max(maxX, point.x);
+ maxY = Math.max(maxY, point.y);
+ }
+
+ const dx = maxX - minX || 1;
+ const dy = maxY - minY || 1;
+ const dmax = Math.max(dx, dy);
+ const mx = (minX + maxX) / 2;
+ const my = (minY + maxY) / 2;
+ const working = points.map((point) => ({ x: point.x, y: point.y }));
+ working.push({ x: mx - 20 * dmax, y: my - dmax });
+ working.push({ x: mx, y: my + 20 * dmax });
+ working.push({ x: mx + 20 * dmax, y: my - dmax });
+
+ const s0 = count;
+ const s1 = count + 1;
+ const s2 = count + 2;
+ let triangles = [[s0, s1, s2]];
+
+ const circumcircle = (a, b, c) => {
+ const ax = working[a].x;
+ const ay = working[a].y;
+ const bx = working[b].x;
+ const by = working[b].y;
+ const cx = working[c].x;
+ const cy = working[c].y;
+ const denominator = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
+ if (Math.abs(denominator) < 1e-9) return null;
+
+ const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / denominator;
+ const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / denominator;
+ return { x: ux, y: uy, r2: (ax - ux) ** 2 + (ay - uy) ** 2 };
+ };
+
+ const triangleHasEdge = (triangle, a, b) => (
+ [[triangle[0], triangle[1]], [triangle[1], triangle[2]], [triangle[2], triangle[0]]]
+ .some((edge) => (edge[0] === a && edge[1] === b) || (edge[0] === b && edge[1] === a))
+ );
+
+ for (let i = 0; i < count; i += 1) {
+ const bad = [];
+ for (const triangle of triangles) {
+ const circle = circumcircle(triangle[0], triangle[1], triangle[2]);
+ if (circle && ((working[i].x - circle.x) ** 2 + (working[i].y - circle.y) ** 2) <= circle.r2 * (1 + 1e-9)) {
+ bad.push(triangle);
+ }
+ }
+
+ const edges = [];
+ for (const triangle of bad) {
+ for (const edge of [[triangle[0], triangle[1]], [triangle[1], triangle[2]], [triangle[2], triangle[0]]]) {
+ const shared = bad.some((other) => other !== triangle && triangleHasEdge(other, edge[0], edge[1]));
+ if (!shared) edges.push(edge);
+ }
+ }
+
+ triangles = triangles.filter((triangle) => !bad.includes(triangle));
+ for (const edge of edges) triangles.push([edge[0], edge[1], i]);
+ }
+
+ return triangles.filter((triangle) => triangle[0] < count && triangle[1] < count && triangle[2] < count);
+}
+
+/**
+ * Warp the source canvas through a single affine triangle into the target
+ * rendering context.
+ *
+ * @param {CanvasRenderingContext2D} ctx - Target rendering context.
+ * @param {HTMLCanvasElement} source - Source matte canvas.
+ * @param {Point} s0 - First source point.
+ * @param {Point} s1 - Second source point.
+ * @param {Point} s2 - Third source point.
+ * @param {Point} d0 - First destination point.
+ * @param {Point} d1 - Second destination point.
+ * @param {Point} d2 - Third destination point.
+ * @returns {void}
+ */
+function warpTriangle(ctx, source, s0, s1, s2, d0, d1, d2) {
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(d0.x, d0.y);
+ ctx.lineTo(d1.x, d1.y);
+ ctx.lineTo(d2.x, d2.y);
+ ctx.closePath();
+ ctx.clip();
+
+ const denominator = (s1.x - s0.x) * (s2.y - s0.y) - (s2.x - s0.x) * (s1.y - s0.y);
+ if (Math.abs(denominator) < 1e-6) {
+ ctx.restore();
+ return;
+ }
+
+ const a = ((d1.x - d0.x) * (s2.y - s0.y) - (d2.x - d0.x) * (s1.y - s0.y)) / denominator;
+ const b = ((d2.x - d0.x) * (s1.x - s0.x) - (d1.x - d0.x) * (s2.x - s0.x)) / denominator;
+ const c = ((d1.y - d0.y) * (s2.y - s0.y) - (d2.y - d0.y) * (s1.y - s0.y)) / denominator;
+ const d = ((d2.y - d0.y) * (s1.x - s0.x) - (d1.y - d0.y) * (s2.x - s0.x)) / denominator;
+ const e = d0.x - a * s0.x - b * s0.y;
+ const f = d0.y - c * s0.x - d * s0.y;
+
+ ctx.setTransform(a, c, b, d, e, f);
+ ctx.drawImage(source, 0, 0);
+ ctx.restore();
+}
+
+/**
+ * Read the current transfer controls as typed values.
+ *
+ * @returns {{threshold:number, feather:number, opacity:number, blend:GlobalCompositeOperation, useMesh:boolean}} Transfer parameters.
+ */
+function readParams() {
+ return {
+ threshold: Number(byId('thr').value),
+ feather: Number(byId('fth').value),
+ opacity: Number(byId('op').value) / 100,
+ blend: byId('blend').value,
+ useMesh: byId('useMesh').checked
+ };
+}
+
+/**
+ * Draw the extracted matte into the side preview canvas.
+ *
+ * @param {HTMLCanvasElement} matte - Canonical matte canvas.
+ * @returns {void}
+ */
+function drawMattePreview(matte) {
+ const preview = byId('matte');
+ preview.width = 180;
+ preview.height = 180;
+ const ctx = ctx2d(preview);
+ ctx.clearRect(0, 0, 180, 180);
+ ctx.drawImage(matte, 0, 0, 180, 180);
+}
+
+/**
+ * Run extraction and compositing, using mesh warping when landmarks are
+ * available and falling back to direct box placement otherwise.
+ *
+ * @returns {Promise} Translation key for the mode used.
+ */
+async function transfer() {
+ const params = readParams();
+ setMessage('transfer_msg_extracting');
+ const matte = extractMatte(params.threshold, params.feather);
+ drawMattePreview(matte);
+
+ const target = slots.target;
+ if (!target.img || !target.box) throw new Error('Target image or box is missing.');
+
+ let resultWidth = target.img.width;
+ let resultHeight = target.img.height;
+ const longEdge = Math.max(resultWidth, resultHeight);
+ if (longEdge > RESULT_MAX) {
+ const ratio = RESULT_MAX / longEdge;
+ resultWidth = Math.round(resultWidth * ratio);
+ resultHeight = Math.round(resultHeight * ratio);
+ }
+
+ const result = document.createElement('canvas');
+ result.width = resultWidth;
+ result.height = resultHeight;
+ const resultCtx = ctx2d(result);
+ resultCtx.drawImage(target.img, 0, 0, resultWidth, resultHeight);
+
+ const scaleX = resultWidth / target.img.width;
+ const scaleY = resultHeight / target.img.height;
+ let modeKey = 'transfer_mode_box';
+ let modeParams = {};
+
+ if (params.useMesh && faceapiReady) {
+ setMessage('transfer_msg_detecting');
+ const [afterLandmarks, targetLandmarks] = await Promise.all([
+ detectLandmarks('after'),
+ detectLandmarks('target')
+ ]);
+
+ if (afterLandmarks && targetLandmarks && slots.after.box) {
+ const sourcePoints = landmarksToCanon(afterLandmarks, slots.after.box);
+ const destinationPoints = targetLandmarks.map((point) => ({ x: point.x * scaleX, y: point.y * scaleY }));
+ const triangles = triangulate(sourcePoints);
+
+ resultCtx.globalCompositeOperation = params.blend;
+ resultCtx.globalAlpha = params.opacity;
+ for (const triangle of triangles) {
+ warpTriangle(
+ resultCtx,
+ matte,
+ sourcePoints[triangle[0]],
+ sourcePoints[triangle[1]],
+ sourcePoints[triangle[2]],
+ destinationPoints[triangle[0]],
+ destinationPoints[triangle[1]],
+ destinationPoints[triangle[2]]
+ );
+ }
+ resultCtx.setTransform(1, 0, 0, 1, 0, 0);
+ resultCtx.globalAlpha = 1;
+ resultCtx.globalCompositeOperation = 'source-over';
+ modeKey = 'transfer_mode_mesh';
+ modeParams = { count: triangles.length };
+ }
+ }
+
+ if (modeKey === 'transfer_mode_box') {
+ const box = target.box;
+ resultCtx.globalCompositeOperation = params.blend;
+ resultCtx.globalAlpha = params.opacity;
+ resultCtx.drawImage(matte, box.x * scaleX, box.y * scaleY, box.w * scaleX, box.h * scaleY);
+ resultCtx.globalAlpha = 1;
+ resultCtx.globalCompositeOperation = 'source-over';
+
+ if (params.useMesh && faceapiReady) {
+ modeKey = 'transfer_mode_box_no_face';
+ } else if (params.useMesh) {
+ modeKey = 'transfer_mode_box_unavailable';
+ }
+ }
+
+ const holder = byId('result');
+ holder.innerHTML = '';
+ holder.appendChild(result);
+ lastResultCanvas = result;
+ byId('download').disabled = false;
+ setModeState(modeKey, modeParams, true);
+ setMessage('transfer_msg_done');
+ return modeKey;
+}
+
+/**
+ * Reset images, boxes, previews, output, and controls that are derived from
+ * the last transfer.
+ *
+ * @returns {void}
+ */
+function resetTransfer() {
+ for (const name of Object.keys(slots)) {
+ slots[name].img = null;
+ slots[name].box = null;
+ fitPreview(name);
+ refreshSlotNote(name);
+ }
+
+ renderResultEmpty();
+ ctx2d(byId('matte')).clearRect(0, 0, 180, 180);
+ setModeState('transfer_mode_not_run');
+ lastResultCanvas = null;
+ byId('download').disabled = true;
+ refreshState();
+}
+
+/**
+ * Wire DOM events for file loading, drag-and-drop, controls, commands, locale
+ * changes, and the lightweight test API.
+ *
+ * @returns {void}
+ */
+function setupTransferPage() {
+ initI18n();
+ setupLocaleSelect(byId('localeSelect'), () => {
+ applyI18n();
+ refreshLocalizedState();
+ });
+ refreshLocalizedState();
+
+ document.querySelectorAll('input[type="file"][data-slot]').forEach((input) => {
+ input.addEventListener('change', (event) => {
+ const file = event.target.files[0];
+ if (file) loadFile(input.dataset.slot, file);
+ });
+ });
+
+ for (const name of Object.keys(slots)) {
+ const stage = byId(`stage-${name}`);
+ stage.addEventListener('dragover', (event) => {
+ event.preventDefault();
+ });
+ stage.addEventListener('drop', (event) => {
+ event.preventDefault();
+ const file = event.dataTransfer.files[0];
+ if (file) loadFile(name, file);
+ });
+ }
+
+ byId('thr').addEventListener('input', (event) => { byId('v-thr').textContent = event.target.value; });
+ byId('fth').addEventListener('input', (event) => { byId('v-fth').textContent = event.target.value; });
+ byId('op').addEventListener('input', (event) => { byId('v-op').textContent = event.target.value; });
+
+ byId('run').addEventListener('click', () => {
+ byId('run').disabled = true;
+ transfer()
+ .catch((err) => {
+ console.error(err);
+ setMessage('transfer_msg_error', { message: err instanceof Error ? err.message : String(err) });
+ })
+ .finally(() => refreshState());
+ });
+
+ byId('download').addEventListener('click', () => {
+ if (!lastResultCanvas) return;
+ lastResultCanvas.toBlob((blob) => {
+ if (!blob) return;
+ const link = document.createElement('a');
+ link.href = URL.createObjectURL(blob);
+ link.download = 'ghostyle-transfer.png';
+ link.click();
+ });
+ });
+
+ byId('reset').addEventListener('click', resetTransfer);
+ window.addEventListener('resize', () => Object.keys(slots).forEach(fitPreview));
+ window.addEventListener('i18n:localeChanged', refreshLocalizedState);
+
+ initFaceapi();
+
+ window.GT = {
+ load: (name, url) => loadURL(name, url),
+ setBox: (name, box) => {
+ slots[name].box = box;
+ fitPreview(name);
+ refreshState();
+ },
+ setParams: (options) => {
+ if (options.threshold != null) {
+ byId('thr').value = options.threshold;
+ byId('v-thr').textContent = options.threshold;
+ }
+ if (options.feather != null) {
+ byId('fth').value = options.feather;
+ byId('v-fth').textContent = options.feather;
+ }
+ if (options.opacity != null) {
+ byId('op').value = options.opacity;
+ byId('v-op').textContent = options.opacity;
+ }
+ if (options.blend != null) byId('blend').value = options.blend;
+ if (options.useMesh != null) byId('useMesh').checked = options.useMesh;
+ },
+ transfer: () => transfer(),
+ resultDataURL: () => (lastResultCanvas ? lastResultCanvas.toDataURL() : null),
+ _internal: { triangulate, warpTriangle, extractMatte, slots }
+ };
+}
+
+setupTransferPage();
diff --git a/scripts/upload-consent.js b/scripts/upload-consent.js
new file mode 100644
index 0000000..8ab0d49
--- /dev/null
+++ b/scripts/upload-consent.js
@@ -0,0 +1,425 @@
+/**
+ * @module upload-consent
+ * @description
+ * Browser-managed clip upload and receipt ownership flow. The recorder emits a
+ * Blob; this module keeps that clip local until explicit consent, sends the
+ * backend multipart payload, and stores the returned delete-token receipt in
+ * localStorage so the user can revoke later from the upload panel.
+ */
+import { APP_VERSION, RECORDING_CONFIG, UPLOAD_CONSENT_VERSION } from './config.js';
+import { t } from './i18n.js';
+import { state } from './state.js';
+import { setLog } from './utils.js';
+
+export const RECEIPTS_STORAGE_KEY = 'ghostmaxxing-upload-receipts-v1';
+const RECEIPT_HASH_PREFIX = '#receipt=';
+
+function $(selector, root = document) {
+ return root.querySelector(selector);
+}
+
+function text(value) {
+ return value == null || value === '' ? 'n/a' : String(value);
+}
+
+function formatBytes(bytes) {
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function randomToken(length = 16) {
+ const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
+ const cryptoApi = globalThis.crypto;
+ const bytes = new Uint8Array(length);
+ if (cryptoApi && typeof cryptoApi.getRandomValues === 'function') {
+ cryptoApi.getRandomValues(bytes);
+ } else {
+ for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
+ }
+ return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');
+}
+
+export function createReceiptCode() {
+ return `GSTMXX-${randomToken(16).match(/.{1,4}/g).join('-')}`;
+}
+
+export function loadUploadReceipts() {
+ try {
+ const raw = localStorage.getItem(RECEIPTS_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+export function persistUploadReceipts(receipts) {
+ localStorage.setItem(RECEIPTS_STORAGE_KEY, JSON.stringify(receipts));
+}
+
+export function publicUrlForReceipt(uploadId, kind = 'video') {
+ const path = kind === 'clipboard'
+ ? `/clipboard/${uploadId}.png`
+ : `/videos/${uploadId}.mp4`;
+ return new URL(path, window.location.origin).href;
+}
+
+export function privateReceiptUrl(uploadId, deleteToken) {
+ const payload = encodeURIComponent(`${uploadId}.${deleteToken}`);
+ return `${window.location.origin}${window.location.pathname}${RECEIPT_HASH_PREFIX}${payload}`;
+}
+
+function parseReceiptHash() {
+ if (!window.location.hash.startsWith(RECEIPT_HASH_PREFIX)) return null;
+ const payload = decodeURIComponent(window.location.hash.slice(RECEIPT_HASH_PREFIX.length));
+ const splitAt = payload.indexOf('.');
+ if (splitAt < 1) return null;
+ const uploadId = payload.slice(0, splitAt);
+ const deleteToken = payload.slice(splitAt + 1);
+ if (!uploadId || !deleteToken) return null;
+ return { uploadId, deleteToken };
+}
+
+function compactMatchMetrics(detail) {
+ if (!detail || typeof detail !== 'object') return null;
+ const faceapi = detail.faceapi || null;
+ const mediapipe = detail.mediapipe || null;
+ return {
+ source: detail.source || null,
+ overall: detail.overall || null,
+ ghostylePresent: !!detail.ghostylePresent,
+ faceapi: faceapi ? {
+ detectionState: faceapi.detectionState || null,
+ liveMinDist: faceapi.liveMinDist ?? faceapi.distance ?? null,
+ obfMinDist: faceapi.obfMinDist ?? null,
+ matchedId: faceapi.matchedId ?? faceapi.liveMinId ?? faceapi.obfMinId ?? null
+ } : null,
+ mediapipe: mediapipe ? {
+ detectionState: mediapipe.detectionState || null,
+ liveMaxSim: mediapipe.liveMaxSim ?? null,
+ obfMaxSim: mediapipe.obfMaxSim ?? null,
+ matchedId: mediapipe.matchedId ?? mediapipe.liveMaxId ?? mediapipe.obfMaxId ?? null
+ } : null
+ };
+}
+
+function buildUploadFormData(clip, note) {
+ const formData = new FormData();
+ formData.append('video', clip.blob, clip.filename);
+ formData.set('kind', 'video');
+ formData.set('consent_version', UPLOAD_CONSENT_VERSION);
+ formData.set('app_version', APP_VERSION);
+ if (clip.ghostyleId) formData.set('ghostyle_id', clip.ghostyleId);
+ if (note) formData.set('user_note', note);
+ if (clip.metrics) formData.set('metrics_json', JSON.stringify(clip.metrics));
+ return formData;
+}
+
+async function postUpload(clip, note) {
+ const response = await fetch(RECORDING_CONFIG.uploadEndpoint, {
+ method: 'POST',
+ body: buildUploadFormData(clip, note)
+ });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok || !body.ok) {
+ const message = body.message || `${response.status} ${response.statusText}`;
+ const err = new Error(message);
+ err.status = response.status;
+ throw err;
+ }
+ return body;
+}
+
+async function deleteUpload(receipt) {
+ const response = await fetch(`${RECORDING_CONFIG.uploadEndpoint}/${encodeURIComponent(receipt.uploadId)}`, {
+ method: 'DELETE',
+ headers: { 'X-Delete-Token': receipt.deleteToken }
+ });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ const err = new Error(body.message || `${response.status} ${response.statusText}`);
+ err.status = response.status;
+ throw err;
+ }
+ return body;
+}
+
+function emitQueueChanged(count) {
+ state.gstmxxEvents.dispatchEvent(new CustomEvent('uploadQueueChanged', { detail: { count } }));
+}
+
+function copyText(value) {
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
+ return navigator.clipboard.writeText(value);
+ }
+ return Promise.reject(new Error('Clipboard API unavailable'));
+}
+
+function addLabelRow(parent, label, value) {
+ const row = document.createElement('div');
+ row.className = 'upload-meta-row';
+ const strong = document.createElement('strong');
+ strong.textContent = label;
+ const span = document.createElement('span');
+ span.textContent = value;
+ row.append(strong, span);
+ parent.append(row);
+}
+
+export function initUploadConsentFlow() {
+ const preview = $('#gm-upload-preview');
+ const empty = $('#gm-upload-empty');
+ const current = $('#gm-upload-current');
+ const clipList = $('#gm-upload-clip-list');
+ const clipMeta = $('#gm-upload-clip-meta');
+ const noteInput = $('#gm-upload-note');
+ const consentInput = $('#gm-upload-consent');
+ const submitBtn = $('#gm-upload-submit');
+ const discardBtn = $('#gm-upload-discard');
+ const statusEl = $('#gm-upload-status');
+ const receiptsEl = $('#gm-receipt-log');
+
+ if (!preview || !current || !clipList || !receiptsEl) return;
+
+ let clips = [];
+ let selectedClipId = null;
+ let receipts = loadUploadReceipts();
+ let previewUrl = null;
+ let lastMetrics = null;
+
+ function selectedClip() {
+ return clips.find((clip) => clip.id === selectedClipId) || null;
+ }
+
+ function setStatus(message, kind = '') {
+ if (!statusEl) return;
+ statusEl.textContent = message || '';
+ statusEl.dataset.kind = kind;
+ }
+
+ function renderClipList() {
+ clipList.replaceChildren();
+ clips.forEach((clip) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'upload-chip';
+ button.classList.toggle('active', clip.id === selectedClipId);
+ button.textContent = `${new Date(clip.recordedAt).toLocaleTimeString()} · ${formatBytes(clip.size)}`;
+ button.addEventListener('click', () => {
+ selectedClipId = clip.id;
+ render();
+ });
+ clipList.append(button);
+ });
+ }
+
+ function renderCurrentClip() {
+ const clip = selectedClip();
+ const hasClip = !!clip;
+ if (empty) empty.hidden = hasClip;
+ current.hidden = !hasClip;
+ if (submitBtn) submitBtn.disabled = !hasClip || !consentInput?.checked;
+ if (!clip) {
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
+ previewUrl = null;
+ preview.removeAttribute('src');
+ return;
+ }
+
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
+ previewUrl = URL.createObjectURL(clip.blob);
+ preview.src = previewUrl;
+
+ clipMeta.replaceChildren();
+ addLabelRow(clipMeta, t('upload_meta_recorded_label'), new Date(clip.recordedAt).toLocaleString());
+ addLabelRow(clipMeta, t('upload_meta_size_label'), formatBytes(clip.size));
+ addLabelRow(clipMeta, t('upload_meta_ghostyle_label'), text(clip.ghostyleId));
+ const overall = clip.metrics?.overall || clip.metrics?.faceapi?.detectionState || null;
+ addLabelRow(clipMeta, t('upload_meta_result_label'), text(overall));
+ }
+
+ function receiptStatusLabel(receipt) {
+ if (receipt.status === 'deleted') return t('receipt_status_deleted');
+ if (receipt.status === 'delete-failed') return t('receipt_status_delete_failed');
+ if (receipt.status === 'imported') return t('receipt_status_imported');
+ return t('receipt_status_pending');
+ }
+
+ function renderReceipts() {
+ receiptsEl.replaceChildren();
+ if (!receipts.length) {
+ const p = document.createElement('p');
+ p.className = 'upload-empty';
+ p.textContent = t('receipt_log_empty');
+ receiptsEl.append(p);
+ return;
+ }
+
+ receipts.forEach((receipt) => {
+ const article = document.createElement('article');
+ article.className = 'receipt-card';
+
+ const head = document.createElement('div');
+ head.className = 'receipt-card__head';
+ const title = document.createElement('h3');
+ title.textContent = receipt.receiptCode || receipt.uploadId;
+ const status = document.createElement('span');
+ status.className = 'receipt-status';
+ status.textContent = receiptStatusLabel(receipt);
+ head.append(title, status);
+
+ const meta = document.createElement('div');
+ meta.className = 'upload-meta';
+ addLabelRow(meta, t('receipt_upload_id_label'), receipt.uploadId);
+ addLabelRow(meta, t('receipt_consent_label'), receipt.consentVersion || UPLOAD_CONSENT_VERSION);
+ addLabelRow(meta, t('upload_meta_ghostyle_label'), text(receipt.ghostyleId));
+
+ const actions = document.createElement('div');
+ actions.className = 'receipt-actions';
+
+ const publicLink = document.createElement('a');
+ publicLink.className = 'secondary-btn';
+ publicLink.href = receipt.publicUrl;
+ publicLink.target = '_blank';
+ publicLink.rel = 'noopener noreferrer';
+ publicLink.textContent = t('receipt_open_public_button');
+
+ const copy = document.createElement('button');
+ copy.type = 'button';
+ copy.className = 'secondary-btn';
+ copy.textContent = t('receipt_copy_private_button');
+ copy.addEventListener('click', async () => {
+ try {
+ await copyText(receipt.privateUrl);
+ setStatus(t('receipt_copied_status'), 'ok');
+ } catch (err) {
+ setStatus(t('receipt_copy_failed_status'), 'error');
+ }
+ });
+
+ const revoke = document.createElement('button');
+ revoke.type = 'button';
+ revoke.className = 'secondary-btn warn';
+ revoke.disabled = receipt.status === 'deleted';
+ revoke.textContent = t('receipt_revoke_button');
+ revoke.addEventListener('click', async () => {
+ revoke.disabled = true;
+ setStatus(t('receipt_revoke_working_status'), 'working');
+ try {
+ await deleteUpload(receipt);
+ receipt.status = 'deleted';
+ receipt.deletedAt = new Date().toISOString();
+ persistUploadReceipts(receipts);
+ setStatus(t('receipt_revoke_done_status'), 'ok');
+ } catch (err) {
+ receipt.status = err.status === 410 ? 'deleted' : 'delete-failed';
+ receipt.lastError = err.message;
+ persistUploadReceipts(receipts);
+ setStatus(t('receipt_revoke_failed_status', { message: err.message }), 'error');
+ }
+ renderReceipts();
+ });
+
+ actions.append(publicLink, copy, revoke);
+ article.append(head, meta, actions);
+ receiptsEl.append(article);
+ });
+ }
+
+ function render() {
+ renderClipList();
+ renderCurrentClip();
+ renderReceipts();
+ emitQueueChanged(clips.length);
+ }
+
+ function importReceiptFromHash() {
+ const imported = parseReceiptHash();
+ if (!imported) return;
+ if (receipts.some((receipt) => receipt.uploadId === imported.uploadId)) return;
+ const receipt = {
+ id: imported.uploadId,
+ uploadId: imported.uploadId,
+ deleteToken: imported.deleteToken,
+ receiptCode: createReceiptCode(),
+ kind: 'video',
+ consentVersion: UPLOAD_CONSENT_VERSION,
+ publicUrl: publicUrlForReceipt(imported.uploadId, 'video'),
+ privateUrl: privateReceiptUrl(imported.uploadId, imported.deleteToken),
+ status: 'imported',
+ createdAt: new Date().toISOString()
+ };
+ receipts.unshift(receipt);
+ persistUploadReceipts(receipts);
+ }
+
+ state.gstmxxEvents.addEventListener('matchStateChanged', (event) => {
+ lastMetrics = compactMatchMetrics(event.detail);
+ });
+
+ state.gstmxxEvents.addEventListener('clipRecorded', (event) => {
+ const clip = {
+ ...event.detail,
+ metrics: lastMetrics
+ };
+ clips.unshift(clip);
+ selectedClipId = clip.id;
+ if (consentInput) consentInput.checked = false;
+ setStatus(t('upload_clip_ready_status'), 'ok');
+ render();
+ });
+
+ consentInput?.addEventListener('change', renderCurrentClip);
+
+ discardBtn?.addEventListener('click', () => {
+ const clip = selectedClip();
+ if (!clip) return;
+ clips = clips.filter((item) => item.id !== clip.id);
+ selectedClipId = clips[0]?.id || null;
+ setStatus(t('upload_clip_discarded_status'), 'ok');
+ render();
+ });
+
+ submitBtn?.addEventListener('click', async () => {
+ const clip = selectedClip();
+ if (!clip || !consentInput?.checked) return;
+ const note = (noteInput?.value || '').trim();
+ submitBtn.disabled = true;
+ setStatus(t('video_uploading_log'), 'working');
+ try {
+ const body = await postUpload(clip, note);
+ const receipt = {
+ id: body.uploadId,
+ uploadId: body.uploadId,
+ deleteToken: body.deleteToken,
+ receiptCode: createReceiptCode(),
+ kind: 'video',
+ consentVersion: UPLOAD_CONSENT_VERSION,
+ ghostyleId: clip.ghostyleId || null,
+ appVersion: APP_VERSION,
+ userNote: note,
+ publicUrl: publicUrlForReceipt(body.uploadId, 'video'),
+ privateUrl: privateReceiptUrl(body.uploadId, body.deleteToken),
+ status: 'pending',
+ createdAt: new Date().toISOString()
+ };
+ receipts.unshift(receipt);
+ persistUploadReceipts(receipts);
+ clips = clips.filter((item) => item.id !== clip.id);
+ selectedClipId = clips[0]?.id || null;
+ if (noteInput) noteInput.value = '';
+ if (consentInput) consentInput.checked = false;
+ setLog(t('video_upload_done_log', { status: 201, filename: clip.filename }));
+ setStatus(t('upload_receipt_created_status'), 'ok');
+ } catch (err) {
+ setStatus(t('video_upload_network_error_log', { message: err.message }), 'error');
+ }
+ render();
+ });
+
+ importReceiptFromHash();
+ render();
+}
diff --git a/sitemap.xml b/sitemap.xml
index 08c7005..b6edfd4 100644
--- a/sitemap.xml
+++ b/sitemap.xml
@@ -67,4 +67,26 @@
weekly
0.9
+
+
+
+ https://sindacato.nina.watch/ghostati/loader.html
+ 2026-07-22
+ monthly
+ 0.50
+
+
+
+ https://sindacato.nina.watch/ghostati/ghostyle-transfer.html
+ 2026-07-22
+ monthly
+ 0.50
+
+
+
+ https://sindacato.nina.watch/ghostati/realtime.html
+ 2026-07-22
+ monthly
+ 0.50
+
diff --git a/styles/content-pages.css b/styles/content-pages.css
index 598273b..7e0bc87 100644
--- a/styles/content-pages.css
+++ b/styles/content-pages.css
@@ -243,6 +243,303 @@
margin-top: 1.5rem;
}
+.tool-workbench {
+ display: grid;
+ gap: 1.4rem;
+}
+
+.tool-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.tool-card,
+.tool-panel,
+.tool-controls {
+ border: 1px solid var(--gm-border-strong);
+ background: var(--gm-panel);
+ box-shadow: var(--gm-shadow-block);
+}
+
+.tool-card {
+ padding: 0.7rem;
+}
+
+.tool-card__header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.5rem;
+}
+
+.tool-card__title {
+ font-family: var(--serif);
+ font-size: 1.05rem;
+ font-weight: 700;
+ color: var(--gm-ink);
+}
+
+.tool-card__tag,
+.tool-note,
+.tool-label,
+.tool-message,
+.tool-panel h2 {
+ font-family: var(--mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
+}
+
+.tool-card__tag,
+.tool-note,
+.tool-label,
+.tool-panel h2 {
+ color: var(--gm-muted);
+ font-size: 0.68rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.tool-stage {
+ position: relative;
+ display: grid;
+ place-items: center;
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ overflow: hidden;
+ background: rgba(5, 5, 5, 0.14);
+}
+
+.tool-stage canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ touch-action: none;
+}
+
+.tool-stage__hint,
+.tool-result__empty {
+ padding: 1rem;
+ color: var(--gm-cream);
+ font-family: var(--mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 0.78rem;
+ line-height: 1.5;
+ text-align: center;
+}
+
+.tool-note {
+ min-height: 1.1em;
+ margin: 0.45rem 0 0;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.file-picker {
+ display: block;
+ margin-top: 0.5rem;
+}
+
+.file-picker input {
+ display: none;
+}
+
+.file-picker span {
+ display: block;
+ padding: 0.5rem;
+ border: 1px solid var(--gm-border-strong);
+ background: var(--gm-cream);
+ color: var(--gm-ink);
+ cursor: pointer;
+ font-weight: 700;
+ font-size: 0.85rem;
+ text-align: center;
+}
+
+.file-picker span:hover,
+.file-picker input:focus-visible + span {
+ background: var(--gm-yellow);
+}
+
+.tool-controls {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 1rem 1.4rem;
+ align-items: end;
+ padding: 1rem 1.1rem;
+}
+
+.tool-field label {
+ display: block;
+ margin-bottom: 0.35rem;
+}
+
+.tool-label {
+ line-height: 1.2;
+}
+
+.tool-label output {
+ color: var(--gm-ink);
+}
+
+.tool-field input[type="range"] {
+ width: 100%;
+ accent-color: var(--gm-green);
+}
+
+.tool-field select {
+ width: 100%;
+ padding: 0.4rem;
+ border: 1px solid var(--gm-border-strong);
+ background: #fff;
+ color: var(--gm-ink);
+ font-family: var(--sans);
+}
+
+.tool-field--check {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.tool-field--check input {
+ width: 1.1rem;
+ height: 1.1rem;
+ accent-color: var(--gm-green);
+}
+
+.tool-field--check label {
+ margin: 0;
+ color: var(--gm-text);
+ font-size: 0.88rem;
+ font-weight: 650;
+}
+
+.tool-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem;
+ align-items: center;
+}
+
+.tool-actions .btn-primary,
+.tool-actions .link-secondary {
+ min-height: 3rem;
+}
+
+.tool-actions .btn-primary[disabled],
+.tool-actions .link-secondary[disabled] {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.tool-actions .link-secondary {
+ padding: 0.76rem 1rem;
+ border: 1.5px solid currentColor;
+ background: transparent;
+ cursor: pointer;
+}
+
+.tool-actions .link-secondary:hover:not([disabled]),
+.tool-actions .link-secondary:focus-visible:not([disabled]) {
+ background: var(--gm-ink);
+ color: var(--gm-cream);
+ outline: none;
+}
+
+.tool-message {
+ color: var(--gm-text);
+ font-size: 0.78rem;
+}
+
+.tool-output {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(16rem, 300px);
+ gap: 1.2rem;
+ align-items: start;
+}
+
+.tool-result {
+ display: grid;
+ min-height: 280px;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid var(--gm-border-strong);
+ background: rgba(5, 5, 5, 0.14);
+ box-shadow: var(--gm-shadow-block);
+}
+
+.tool-result canvas {
+ display: block;
+ max-width: 100%;
+ height: auto;
+}
+
+.tool-aside {
+ display: grid;
+ gap: 1rem;
+}
+
+.tool-panel {
+ padding: 0.8rem;
+}
+
+.tool-panel h2 {
+ margin: 0 0 0.5rem;
+ font-family: var(--mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
+ line-height: 1;
+}
+
+.tool-panel canvas {
+ width: 100%;
+ height: auto;
+ border: 1px solid var(--gm-border);
+ background: conic-gradient(rgba(0, 0, 0, 0.13) 25%, transparent 0 50%, rgba(0, 0, 0, 0.13) 0 75%, transparent 0) 0 / 16px 16px;
+}
+
+.tool-panel p {
+ margin-top: 0.5rem;
+ font-size: 0.82rem;
+ line-height: 1.5;
+}
+
+.tool-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ width: fit-content;
+ margin-top: 1rem;
+ padding: 0.34rem 0.62rem;
+ border: 1px solid var(--gm-border-strong);
+ background: var(--gm-panel);
+ color: var(--gm-text);
+ font-family: var(--mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
+ font-size: 0.72rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.tool-status__dot {
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 50%;
+ background: var(--gm-muted);
+}
+
+.tool-status.is-ready .tool-status__dot {
+ background: var(--gm-green-soft);
+}
+
+.tool-disclaimer {
+ margin-top: 2.4rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--gm-border);
+}
+
+.tool-disclaimer p {
+ color: var(--gm-muted);
+ font-size: 0.9rem;
+}
+
@media (max-width: 980px) {
.content-page__layout {
grid-template-columns: 1fr;
@@ -258,6 +555,11 @@
flex-wrap: wrap;
gap: 0.7rem 1rem;
}
+
+ .tool-grid,
+ .tool-output {
+ grid-template-columns: 1fr;
+ }
}
@media (max-width: 680px) {
diff --git a/styles/lab.css b/styles/lab.css
index 27ce5a9..a86dd0c 100644
--- a/styles/lab.css
+++ b/styles/lab.css
@@ -440,6 +440,59 @@ input[type="color"] { width: 40px; height: 28px; border: 0; background: none; pa
.card + .card { margin-top: 10px; }
.cardlist { display: flex; flex-direction: column; gap: 10px; margin-top: 14px; }
+.upload-preview {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ border-radius: 10px;
+ background: #000;
+ border: 1px solid var(--line-soft);
+ object-fit: contain;
+ margin-top: 10px;
+}
+.upload-empty { color: var(--faint); font-size: 13px; }
+.upload-meta { margin: 10px 0; border-top: 1px solid var(--line-soft); }
+.upload-meta-row {
+ display: flex; justify-content: space-between; gap: 12px;
+ padding: 7px 0; border-bottom: 1px solid var(--line-soft);
+ font-size: 12px; color: var(--muted);
+}
+.upload-meta-row strong { color: var(--cream); font-weight: 600; }
+.upload-meta-row span { text-align: right; overflow-wrap: anywhere; }
+.upload-chiplist { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 12px; }
+.upload-chip {
+ border: 1px solid var(--line-soft); background: var(--panel-2); color: var(--muted);
+ border-radius: 999px; padding: 5px 9px; font: 11px var(--mono); cursor: pointer;
+}
+.upload-chip.active { color: #1a130b; background: var(--cream); border-color: var(--cream); }
+.upload-field { display: grid; gap: 6px; margin: 12px 0; font-size: 13px; color: var(--cream); }
+.upload-field textarea {
+ width: 100%; resize: vertical; min-height: 72px; max-height: 150px;
+ border-radius: 10px; border: 1px solid var(--line); background: var(--panel-2);
+ color: var(--fg); padding: 9px 10px; font: 13px/1.4 var(--sans);
+}
+.consent-check {
+ display: grid; grid-template-columns: auto 1fr; gap: 9px; align-items: start;
+ margin: 12px 0; color: var(--muted); font-size: 12.5px; line-height: 1.45;
+}
+.consent-check input { margin-top: 3px; accent-color: var(--orange); }
+.upload-actions, .receipt-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+.upload-status { min-height: 18px; margin-bottom: 0; font: 12px var(--mono); color: var(--muted); }
+.upload-status[data-kind="ok"] { color: var(--dev); }
+.upload-status[data-kind="error"] { color: #ff9a81; }
+.upload-status[data-kind="working"] { color: var(--yellow); }
+.receipt-log { display: grid; gap: 10px; }
+.receipt-card { border: 1px solid var(--line-soft); border-radius: 10px; padding: 11px; background: rgba(12, 9, 6, .22); }
+.receipt-card__head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.receipt-card__head h3 { margin: 0; font: 700 12px var(--mono); color: var(--cream); overflow-wrap: anywhere; }
+.receipt-status {
+ border: 1px solid var(--line-soft); border-radius: 999px; padding: 3px 7px;
+ color: var(--net); font: 700 10px var(--mono); white-space: nowrap;
+}
+.receipt-actions .secondary-btn { text-decoration: none; display: inline-flex; align-items: center; }
+.secondary-btn.warn { border-color: rgba(253, 85, 3, .5); color: #ffb98b; }
+.secondary-btn:disabled { opacity: .45; cursor: not-allowed; }
+
/* engine-rendered saved-face cards (#historyEntries) */
.history-empty { grid-column: 1 / -1; color: var(--faint); font-size: 13px; }
.history-card { display: flex; flex-direction: column; gap: 6px; background: var(--panel); border: 1px solid var(--line-soft); border-radius: 14px; padding: 10px; }
diff --git a/styles/loader.css b/styles/loader.css
new file mode 100644
index 0000000..4b19adc
--- /dev/null
+++ b/styles/loader.css
@@ -0,0 +1,358 @@
+.loader-page {
+ min-height: 100svh;
+ overflow: auto;
+}
+
+.loader-shell {
+ width: min(100%, 1440px);
+ min-height: 100svh;
+ margin: 0 auto;
+ padding: 1.25rem;
+ display: grid;
+ grid-template-rows: auto auto minmax(0, 1.1fr) auto minmax(13rem, 0.8fr);
+ gap: 0.9rem;
+}
+
+.loader-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1.5rem;
+ padding-bottom: 0.85rem;
+ border-bottom: 1px solid var(--gm-border);
+}
+
+.loader-header__tools {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+}
+
+.loader-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.55rem;
+ min-height: 2.6rem;
+ padding: 0.75rem 1rem;
+ border: 1.5px solid var(--gm-border-strong);
+ background: rgba(255, 231, 168, 0.18);
+ color: var(--gm-ink);
+ font: 700 0.82rem var(--sans);
+ text-transform: uppercase;
+}
+
+.status-dot {
+ width: 0.7rem;
+ height: 0.7rem;
+ border-radius: 50%;
+ background: var(--gm-muted);
+}
+
+.status-dot.live {
+ background: var(--gm-green);
+}
+
+.status-dot.error {
+ background: #9a1616;
+}
+
+.loader-title {
+ margin: 0;
+ font-family: var(--serif);
+ font-size: clamp(2.2rem, 4vw, 4rem);
+ line-height: 0.95;
+ letter-spacing: 0;
+ color: var(--gm-ink);
+}
+
+.loader-band {
+ min-width: 0;
+ border: 1.5px solid var(--gm-border-strong);
+ background: rgba(255, 231, 168, 0.2);
+ box-shadow: var(--gm-shadow-block);
+}
+
+.loader-band--stage {
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) auto;
+ min-height: 24rem;
+}
+
+.loader-viewer {
+ position: relative;
+ min-height: 0;
+ overflow: hidden;
+ background: #050505;
+}
+
+#video,
+#previewImage,
+#overlay,
+#mesh3dOverlay,
+#bboxOverlay {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+#video {
+ z-index: 1;
+ background: #050505;
+}
+
+#previewImage {
+ display: none;
+}
+
+#overlay {
+ z-index: 2;
+ pointer-events: none;
+}
+
+#mesh3dOverlay {
+ z-index: 3;
+ pointer-events: none;
+}
+
+#bboxOverlay {
+ z-index: 4;
+ pointer-events: none;
+}
+
+.loader-placeholder {
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ display: grid;
+ place-items: center;
+ padding: 1.5rem;
+ color: var(--gm-cream);
+ background: linear-gradient(135deg, rgba(5, 5, 5, 0.92), rgba(6, 63, 50, 0.72));
+ font: 700 1rem var(--sans);
+ text-align: center;
+}
+
+.loader-controls,
+.loader-band--actions {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ padding: 0.8rem;
+}
+
+.file-control,
+.loader-action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 2.9rem;
+ padding: 0.75rem 1.15rem;
+ border: 1.5px solid var(--gm-border-strong);
+ background: var(--gm-cream);
+ color: var(--gm-ink);
+ font: 800 0.95rem var(--sans);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.file-control input {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.loader-action {
+ background: var(--gm-green);
+ color: var(--gm-cream);
+}
+
+.loader-action:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.file-control:focus-within,
+.loader-action:focus-visible,
+#videoSeek:focus-visible {
+ outline: var(--gm-focus-ring);
+ outline-offset: 0.15rem;
+}
+
+.timeline {
+ flex: 1;
+ min-width: 16rem;
+ display: grid;
+ grid-template-columns: 6.5rem minmax(0, 1fr) 6.5rem;
+ align-items: center;
+ gap: 0.75rem;
+ font: 700 0.82rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ color: var(--gm-ink);
+}
+
+#videoSeek {
+ width: 100%;
+ accent-color: var(--gm-green);
+}
+
+.loader-band--actions {
+ justify-content: space-between;
+ flex-wrap: wrap;
+}
+
+.loader-readout {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ flex-wrap: wrap;
+ color: var(--gm-text);
+ font: 700 0.8rem var(--sans);
+}
+
+.loader-readout strong {
+ min-width: 2.5rem;
+ padding: 0.35rem 0.45rem;
+ background: rgba(255, 231, 168, 0.45);
+ border: 1px solid var(--gm-border);
+ color: var(--gm-ink);
+ text-align: center;
+}
+
+.loader-band--log {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.loader-log-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.8rem 0.95rem;
+ border-bottom: 1px solid var(--gm-border);
+}
+
+.loader-log-head h2 {
+ margin: 0;
+ font-family: var(--serif);
+ font-size: 1.55rem;
+ color: var(--gm-ink);
+}
+
+.loader-log-head p {
+ margin: 0;
+ color: var(--gm-muted);
+ font-size: 0.9rem;
+}
+
+.loader-log {
+ overflow: auto;
+ padding: 0.85rem;
+ display: grid;
+ gap: 0.8rem;
+ align-content: start;
+}
+
+.loader-entry {
+ display: grid;
+ grid-template-columns: minmax(13rem, 18rem) minmax(0, 1fr);
+ gap: 0.85rem;
+ padding: 0.8rem;
+ border: 1px solid var(--gm-border);
+ background: rgba(255, 231, 168, 0.24);
+}
+
+.loader-entry canvas {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: #050505;
+ border: 1px solid var(--gm-border-strong);
+ object-fit: contain;
+}
+
+.loader-entry__body {
+ min-width: 0;
+}
+
+.loader-entry__body h3 {
+ margin: 0 0 0.35rem;
+ color: var(--gm-ink);
+ font-size: 1rem;
+}
+
+.loader-entry__meta,
+.loader-entry__metrics {
+ display: flex;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+ margin: 0.35rem 0;
+}
+
+.loader-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 1.7rem;
+ padding: 0.25rem 0.45rem;
+ background: rgba(255, 231, 168, 0.5);
+ border: 1px solid var(--gm-border);
+ color: var(--gm-ink);
+ font: 700 0.76rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.loader-entry pre {
+ max-height: 11rem;
+ overflow: auto;
+ margin: 0.55rem 0 0;
+ padding: 0.65rem;
+ background: rgba(5, 5, 5, 0.86);
+ color: var(--gm-cream);
+ font: 0.74rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ white-space: pre-wrap;
+}
+
+.u-hidden {
+ display: none !important;
+}
+
+@media (max-width: 780px) {
+ .loader-shell {
+ grid-template-rows: auto auto auto auto minmax(14rem, 1fr);
+ padding: 0.8rem;
+ }
+
+ .loader-header,
+ .loader-controls,
+ .loader-band--actions,
+ .loader-log-head {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .loader-band--stage {
+ min-height: 20rem;
+ }
+
+ .timeline {
+ min-width: 0;
+ grid-template-columns: 1fr;
+ }
+
+ .loader-header__tools {
+ justify-content: flex-start;
+ }
+
+ .loader-readout {
+ margin-left: 0;
+ }
+
+ .loader-entry {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/styles/realtime.css b/styles/realtime.css
new file mode 100644
index 0000000..51a46df
--- /dev/null
+++ b/styles/realtime.css
@@ -0,0 +1,236 @@
+#ui-layer,
+#ui-layer * {
+ box-sizing: border-box;
+}
+
+#ui-layer {
+ width: 100vw;
+ height: 100vh;
+ display: grid;
+ grid-template-columns: 240px 1fr 240px;
+ gap: 16px;
+ padding: 16px;
+}
+
+.realtime-home-link {
+ position: fixed;
+ top: 12px;
+ left: 12px;
+ z-index: 30;
+}
+
+#graph-container,
+#embedding-panel {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--line-soft);
+ border-radius: 12px;
+ background: rgba(12, 9, 6, 0.52);
+ backdrop-filter: blur(8px);
+ min-height: 0;
+}
+
+.panel-header {
+ border-bottom: 1px solid var(--line-soft);
+ padding: 12px;
+}
+
+.panel-title,
+.panel-value,
+.panel-note {
+ margin: 0;
+}
+
+.panel-title {
+ font: 700 10px var(--mono);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--faint);
+}
+
+.panel-value {
+ margin-top: 8px;
+ font: 400 22px/1 var(--serif);
+ color: var(--yellow);
+ font-variant-numeric: tabular-nums;
+}
+
+.panel-note {
+ padding: 10px 12px;
+ border-top: 1px solid var(--line-soft);
+ color: var(--muted);
+ font: 11px var(--mono);
+}
+
+#distance-graph {
+ width: 100%;
+ height: calc(100% - 92px);
+ min-height: 0;
+ display: block;
+}
+
+#center-view {
+ position: relative;
+ border-radius: 14px;
+ overflow: hidden;
+ border: 1px solid var(--line);
+}
+
+#webcam {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transform: scaleX(-1);
+ filter: brightness(0.72);
+}
+
+.video-scrim {
+ position: absolute;
+ inset: 0;
+ background:
+ linear-gradient(to bottom, rgba(12, 9, 6, 0.45) 0%, rgba(12, 9, 6, 0.05) 40%, rgba(12, 9, 6, 0.58) 100%),
+ repeating-linear-gradient(
+ to right,
+ rgba(255, 231, 168, 0.04) 0,
+ rgba(255, 231, 168, 0.04) 1px,
+ transparent 1px,
+ transparent 12px
+ );
+ pointer-events: none;
+}
+
+#status-pill {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ background: rgba(12, 9, 6, 0.6);
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ padding: 7px 12px;
+ color: var(--muted);
+ font: 11px var(--mono);
+ letter-spacing: 0.04em;
+ backdrop-filter: blur(8px);
+}
+
+#calibration-ui {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+}
+
+#countdown-circle {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ border: 3px solid var(--cream);
+ color: var(--cream);
+ background: rgba(12, 9, 6, 0.62);
+ font-size: 36px;
+ font-weight: 700;
+ font-family: var(--serif);
+}
+
+#countdown-circle.waiting {
+ width: auto;
+ min-height: 52px;
+ height: auto;
+ padding: 8px 16px;
+ border: 1px solid var(--line);
+ color: var(--muted);
+ border-radius: 999px;
+ font: 12px var(--mono);
+}
+
+#btn-reset {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ min-width: 9rem;
+ min-height: 44px;
+ padding: 0.72rem 1.2rem;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ color: var(--fg);
+ background: rgba(20, 16, 12, 0.5);
+ font: 600 13px var(--sans);
+ transition: transform 0.12s ease, background 0.2s ease, border-color 0.2s ease;
+ cursor: pointer;
+ backdrop-filter: blur(8px);
+}
+
+#btn-reset:hover {
+ border-color: rgba(255, 231, 168, 0.24);
+ background: rgba(33, 26, 18, 0.86);
+}
+
+#btn-reset:active {
+ transform: scale(0.97);
+}
+
+#btn-reset:focus-visible {
+ outline: 2px solid var(--yellow);
+ outline-offset: 3px;
+}
+
+#embedding-bar {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding: 8px;
+ height: calc(100% - 66px);
+ overflow: hidden;
+}
+
+.descriptor-row {
+ display: grid;
+ grid-template-columns: repeat(10, 1fr);
+ gap: 1px;
+ flex: 1;
+}
+
+.cell {
+ background: var(--panel-2);
+}
+
+.cell.on-low {
+ background: var(--dev);
+}
+
+.cell.on-med {
+ background: var(--yellow);
+}
+
+.cell.on-high {
+ background: var(--orange);
+}
+
+@media (max-width: 1050px) {
+ #ui-layer {
+ grid-template-columns: 1fr;
+ grid-template-rows: 160px minmax(0, 1fr) 220px;
+ padding: 10px;
+ gap: 10px;
+ }
+
+ #graph-container,
+ #embedding-panel {
+ min-height: 0;
+ }
+
+ #distance-graph {
+ height: calc(100% - 70px);
+ }
+
+ #embedding-bar {
+ overflow-y: auto;
+ }
+}
\ No newline at end of file
diff --git a/tests/e2e/loader.spec.js b/tests/e2e/loader.spec.js
new file mode 100644
index 0000000..31f3c18
--- /dev/null
+++ b/tests/e2e/loader.spec.js
@@ -0,0 +1,208 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('MP4 loader tool', () => {
+ test('boots the internal loader interface with mocked model runtimes', async ({ page }) => {
+ await page.addInitScript(() => {
+ localStorage.removeItem('local-face-lab-db-v1');
+ localStorage.removeItem('local-face-lab-db-3d-v1');
+ localStorage.removeItem('local-face-lab-thumbnails-v1');
+ localStorage.removeItem('ghostati-overlay-mode-v1');
+ localStorage.removeItem('ghostmaxxing-locale');
+ });
+
+ await page.route('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/dist/face-api.js', (route) => {
+ route.fulfill({
+ contentType: 'application/javascript',
+ body: `
+ window.faceapi = {
+ TinyFaceDetectorOptions: class TinyFaceDetectorOptions { constructor(opts) { this.opts = opts; } },
+ nets: {
+ tinyFaceDetector: { loadFromUri: async () => {} },
+ faceLandmark68Net: { loadFromUri: async () => {} },
+ faceRecognitionNet: { loadFromUri: async () => {} },
+ ageGenderNet: { loadFromUri: async () => {} },
+ },
+ detectSingleFace: () => null,
+ resizeResults: (result) => result,
+ };
+ `,
+ });
+ });
+
+ await page.route('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35', (route) => {
+ route.fulfill({
+ contentType: 'application/javascript',
+ body: `
+ export class FilesetResolver {
+ static async forVisionTasks() { return {}; }
+ }
+ export class ImageEmbedder {
+ static async createFromOptions() {
+ return { embedForVideo: () => ({ embeddings: [{ floatEmbedding: new Float32Array([1, 0, 0]) }] }) };
+ }
+ }
+ export class FaceLandmarker {
+ static async createFromOptions() {
+ return { detectForVideo: () => ({ faceLandmarks: [] }) };
+ }
+ }
+ `,
+ });
+ });
+
+ await page.goto('/loader.html');
+
+ await expect(page.getByRole('heading', { name: 'Makeup Video Test Loader' })).toBeVisible();
+ await expect(page.getByLabel('Load and play video')).toBeVisible();
+ await expect(page.getByLabel('Face actions')).toBeVisible();
+ await expect(page.getByLabel('Button activity log')).toBeVisible();
+ await expect(page.getByText('Select a local MP4 video to begin.')).toBeVisible();
+ await expect(page.locator('#statusText')).toHaveText('ready', { timeout: 5000 });
+ await page.locator('#localeSelect').selectOption('it');
+ await expect(page.getByRole('heading', { name: 'Loader di test video makeup' })).toBeVisible();
+ await expect(page.getByText('Seleziona un video MP4 locale per iniziare.')).toBeVisible();
+ await expect(page.locator('#statusText')).toHaveText('pronto');
+ await expect(page.locator('#recordFaceBtn')).toBeDisabled();
+ await expect(page.locator('#seekFaceBtn')).toBeDisabled();
+ });
+
+ test('loads a mock video and enables/executes face actions', async ({ page }) => {
+ await page.addInitScript(() => {
+ localStorage.removeItem('local-face-lab-db-v1');
+ localStorage.removeItem('local-face-lab-db-3d-v1');
+ localStorage.removeItem('local-face-lab-thumbnails-v1');
+ localStorage.removeItem('ghostati-overlay-mode-v1');
+ localStorage.removeItem('ghostmaxxing-locale');
+ const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
+ HTMLCanvasElement.prototype.toDataURL = function(type, quality) {
+ if (type === 'image/jpeg' && quality === 0.8) return 'data:image/jpeg;base64,loader-preview';
+ return originalToDataURL.call(this, type, quality);
+ };
+ CanvasRenderingContext2D.prototype.drawImage = function() {};
+ });
+
+ await page.route('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/dist/face-api.js', (route) => {
+ route.fulfill({
+ contentType: 'application/javascript',
+ body: `
+ window.faceapi = {
+ TinyFaceDetectorOptions: class TinyFaceDetectorOptions { constructor(opts) { this.opts = opts; } },
+ nets: {
+ tinyFaceDetector: { loadFromUri: async () => {} },
+ faceLandmark68Net: { loadFromUri: async () => {} },
+ faceRecognitionNet: { loadFromUri: async () => {} },
+ ageGenderNet: { loadFromUri: async () => {} },
+ },
+ detectSingleFace: () => {
+ const landmarks = {
+ positions: Array.from({ length: 68 }, (_, i) => ({ x: 100 + i, y: 120 + i })),
+ getLeftEye: () => [{ x: 230, y: 220 }, { x: 250, y: 220 }],
+ getRightEye: () => [{ x: 320, y: 220 }, { x: 340, y: 220 }],
+ getNose: () => [{ x: 280, y: 240 }, { x: 282, y: 252 }, { x: 284, y: 264 }, { x: 286, y: 276 }],
+ getJawOutline: () => [{ x: 220, y: 300 }, { x: 280, y: 335 }, { x: 350, y: 300 }],
+ getMouth: () => [{ x: 260, y: 310 }, { x: 275, y: 318 }, { x: 290, y: 320 }, { x: 305, y: 318 }, { x: 320, y: 310 }, { x: 290, y: 326 }, { x: 275, y: 318 }],
+ };
+ const result = {
+ detection: { score: 0.93, box: { x: 210, y: 160, width: 180, height: 220 } },
+ landmarks,
+ age: 31,
+ gender: 'female',
+ genderProbability: 0.82,
+ descriptor: Array.from({ length: 128 }, (_, i) => i / 128),
+ };
+ return {
+ withFaceLandmarks: () => ({
+ withAgeAndGender: () => ({
+ withFaceDescriptor: async () => result,
+ }),
+ }),
+ };
+ },
+ resizeResults: (result) => result,
+ };
+ `,
+ });
+ });
+
+ await page.route('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35', (route) => {
+ route.fulfill({
+ contentType: 'application/javascript',
+ body: `
+ export class FilesetResolver {
+ static async forVisionTasks() { return {}; }
+ }
+ export class ImageEmbedder {
+ static async createFromOptions() {
+ return { embedForVideo: () => ({ embeddings: [{ floatEmbedding: new Float32Array([1, 0, 0]) }] }) };
+ }
+ }
+ export class FaceLandmarker {
+ static async createFromOptions() {
+ return { detectForVideo: () => ({ faceLandmarks: [] }) };
+ }
+ }
+ `,
+ });
+ });
+
+ await page.goto('/loader.html');
+
+ // Wait for the models to be ready
+ await expect(page.locator('#statusText')).toHaveText('ready', { timeout: 5000 });
+
+ // Upload a mock MP4 file
+ await page.setInputFiles('#videoFile', {
+ name: 'mock-video.mp4',
+ mimeType: 'video/mp4',
+ buffer: Buffer.from('fake mp4 data'),
+ });
+
+ // Mock HTMLVideoElement properties and dispatch loaded metadata/data events
+ await page.evaluate(() => {
+ const video = document.getElementById('video');
+ Object.defineProperties(video, {
+ duration: { value: 12.5, configurable: true },
+ currentTime: { value: 3.2, configurable: true },
+ readyState: { value: 4, configurable: true },
+ videoWidth: { value: 1280, configurable: true },
+ videoHeight: { value: 720, configurable: true },
+ });
+ video.dispatchEvent(new Event('loadedmetadata'));
+ video.dispatchEvent(new Event('loadeddata'));
+ });
+
+ // Verify timeline UI is updated and seek bar is active
+ await expect(page.locator('#durationLabel')).toHaveText('00:12.500');
+ await expect(page.locator('#currentTimeLabel')).toHaveText('00:03.200');
+
+ // Buttons should now be enabled
+ await expect(page.locator('#recordFaceBtn')).toBeEnabled();
+ await expect(page.locator('#seekFaceBtn')).toBeEnabled();
+
+ // Click Record face
+ await page.locator('#recordFaceBtn').click();
+ await expect(page.locator('#loaderLog .loader-entry')).toHaveCount(1);
+ await expect(page.locator('#loaderLog')).toContainText('#1 record face');
+ await expect(page.locator('#loaderLog')).toContainText('preview: saved');
+ await expect.poll(async () => page.evaluate(() => {
+ const store = JSON.parse(localStorage.getItem('local-face-lab-thumbnails-v1') || '{"entries":[]}');
+ const first = store.entries[0] || {};
+ return {
+ count: store.entries.length,
+ id: first.id,
+ dataUrl: first.dataUrl,
+ hasSavedAt: typeof first.savedAt === 'string' && first.savedAt.length > 0,
+ };
+ })).toEqual({
+ count: 1,
+ id: 0,
+ dataUrl: 'data:image/jpeg;base64,loader-preview',
+ hasSavedAt: true,
+ });
+
+ // Click Seek face
+ await page.locator('#seekFaceBtn').click();
+ await expect(page.locator('#loaderLog .loader-entry')).toHaveCount(2);
+ await expect(page.locator('#loaderLog')).toContainText('#2 seek face');
+ });
+});
diff --git a/tests/e2e/overlay-mode.spec.js b/tests/e2e/overlay-mode.spec.js
index 75eb23b..ba5fc9c 100644
--- a/tests/e2e/overlay-mode.spec.js
+++ b/tests/e2e/overlay-mode.spec.js
@@ -12,7 +12,57 @@ test.describe('Ghostmaxxing Overlay Mode E2E', () => {
test('switches the lab view tabs, stores overlay mode, renders 2D/3D overlays, and suppresses after save', async ({ page }) => {
test.setTimeout(90000);
+ // Stub face-api.js CDN so models load instantly without a real network request.
+ // The script sets window.faceapi before any module scripts run (it is a blocking
+ //