From 6006857a528f3a8f3d677fac3a91e4f2ff54b3af Mon Sep 17 00:00:00 2001 From: vecna Date: Thu, 16 Jul 2026 21:17:40 +0200 Subject: [PATCH 01/11] first implementation of video loader page --- loader.html | 97 +++++++++ scripts/loader.js | 433 +++++++++++++++++++++++++++++++++++++++ styles/loader.css | 346 +++++++++++++++++++++++++++++++ tests/e2e/loader.spec.js | 154 ++++++++++++++ 4 files changed, 1030 insertions(+) create mode 100644 loader.html create mode 100644 scripts/loader.js create mode 100644 styles/loader.css create mode 100644 tests/e2e/loader.spec.js diff --git a/loader.html b/loader.html new file mode 100644 index 0000000..e381ad7 --- /dev/null +++ b/loader.html @@ -0,0 +1,97 @@ + + + + + + + Ghostmaxxing | Video Loader + + + + + + + +
+
+ + + + Ghostmaxxing + + Internal video loader + +
+ + loading models +
+
+ +

Makeup Video Test Loader

+ +
+
+
Select a local MP4 video to begin.
+ + diagnostic preview + + + +
+
+ +
+ 00:00.000 + + 00:00.000 +
+
+
+ +
+ + +
+ Faces in memory + 0 + Next ID + 0 + 2D threshold + 0.58 + +
+
+ +
+
+

Activity log

+

Each entry is captured at the exact video timestamp where the button was pressed.

+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + diff --git a/scripts/loader.js b/scripts/loader.js new file mode 100644 index 0000000..f7c14ea --- /dev/null +++ b/scripts/loader.js @@ -0,0 +1,433 @@ +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 } from './i18n.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; + +window.gstmxx = { + log: (message, sourcePlugin) => setLog(message, sourcePlugin), + events: state.gstmxxEvents, + getDb: () => structuredClone(state.db), + getDb3d: () => structuredClone(state.db3d), + getActiveEffect: () => state.activeEffect, + getLastResult: () => state.lastKnownEffectResult, + getMatchThreshold: () => state.MATCH_THRESHOLD, + getMatchThreshold3d: () => state.MATCH_THRESHOLD_3D, + getActiveEffect3d: () => null, + detectorOptions: DETECTOR_OPTIONS, + get lastLandmarks3d() { return state.lastLandmarks3d; }, + set lastLandmarks3d(v) { state.lastLandmarks3d = v; }, +}; + +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')}`; +} + +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)}%)`, + }; +} + +function setActionButtonsEnabled(enabled) { + const ready = enabled && modelsReady && els.video.readyState >= 2 && !actionInFlight; + recordFaceBtn.disabled = !ready; + seekFaceBtn.disabled = !ready; +} + +function updateTimelineFromVideo() { + const position = currentPosition(); + currentTimeLabel.textContent = formatVideoTime(position.current); + durationLabel.textContent = formatVideoTime(position.duration); + if (!seekInput.matches(':active')) seekInput.value = String(position.current); +} + +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; + } +} + +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; +} + +function appendChip(parent, label, value) { + const chip = document.createElement('span'); + chip.className = 'loader-chip'; + chip.textContent = `${label}: ${value}`; + parent.appendChild(chip); +} + +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, 'pressed', new Date().toLocaleString()); + appendChip(meta, '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); +} + +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, + }; +} + +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); +} + +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); +} + +function buildOverall(faceapiSection, mediapipeSection) { + const f = faceapiSection?.detectionState; + const m = mediapipeSection?.detectionState; + if (!f || !m) return 'unknown'; + return f === m ? f : 'partial-elusion'; +} + +async function withActionLock(fn) { + if (actionInFlight) return; + actionInFlight = true; + setActionButtonsEnabled(false); + try { + await fn(); + } catch (err) { + setStatus('error', 'action failed'); + appendEntry('error', snapshotCanvas(), { + position: currentPosition(), + metrics: { error: err.message || String(err) }, + }, { error: err.stack || err.message || String(err) }); + } finally { + actionInFlight = false; + setActionButtonsEnabled(true); + } +} + +async function recordFace() { + await withActionLock(async () => { + syncCanvasSize(); + const position = currentPosition(); + const saved2d = await saveFace(); + if (!saved2d) { + appendEntry('record face', snapshotCanvas(), { + position, + metrics: { result: 'no face detected' }, + }, { message: 'No 2D face-api detection was available at this frame.' }); + return; + } + + 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('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', + }, + }, { + action: 'record face', + savedId: saved2d.id, + video: position, + faceapi: features, + mediapipe: { + saved: !!saved3d, + embeddingLength: embedding ? embedding.length : 0, + selfSimilarity: saved3d?.liveInfo3d?.liveMaxSim ?? null, + }, + }); + }); +} + +async function seekFace() { + await withActionLock(async () => { + syncCanvasSize(); + const position = currentPosition(); + const result = await detectFaceInCam(true); + if (!result) { + appendEntry('seek face', snapshotCanvas(), { + position, + metrics: { result: 'no face detected' }, + }, { message: 'No 2D face-api detection was available at this frame.' }); + 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('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, + }, + }); + }); +} + +async function loadModels() { + setStatus('init', 'loading face-api'); + 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), + ]); + + setStatus('init', 'loading 3D embedder'); + await loadMobileNet(); +} + +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(`Loaded local video: ${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); +} + +async function init() { + initI18n(); + 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; + setStatus('live', 'ready'); + setLog('Loader ready. Select an MP4 and press Record face or Seek face.', 'loader'); + state.gstmxxEvents.dispatchEvent(new CustomEvent('ready', { detail: {} })); + window.dispatchEvent(new CustomEvent('ghostatiReady')); + setActionButtonsEnabled(true); + } catch (err) { + setStatus('error', 'model load failed'); + setLog(`Loader model error: ${err.message || err}`, 'loader'); + } +} + +init(); diff --git a/styles/loader.css b/styles/loader.css new file mode 100644 index 0000000..f96d857 --- /dev/null +++ b/styles/loader.css @@ -0,0 +1,346 @@ +.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-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-readout { + margin-left: 0; + } + + .loader-entry { + grid-template-columns: 1fr; + } +} diff --git a/tests/e2e/loader.spec.js b/tests/e2e/loader.spec.js new file mode 100644 index 0000000..ebb70a2 --- /dev/null +++ b/tests/e2e/loader.spec.js @@ -0,0 +1,154 @@ +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('ghostati-overlay-mode-v1'); + }); + + 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 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('ghostati-overlay-mode-v1'); + }); + + 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'); + + // 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'); + + // 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'); + }); +}); From 2811453f83d8a69d9deec1f5f4448b5455801f9f Mon Sep 17 00:00:00 2001 From: vecna Date: Mon, 20 Jul 2026 11:31:06 +0200 Subject: [PATCH 02/11] ghostyle-transfer.html first concept of ghostyle transfer --- ghostyle-transfer.html | 558 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 ghostyle-transfer.html diff --git a/ghostyle-transfer.html b/ghostyle-transfer.html new file mode 100644 index 0000000..a99ae98 --- /dev/null +++ b/ghostyle-transfer.html @@ -0,0 +1,558 @@ + + + + + +Ghostmaxxing — Ghostyle Transfer (lab) + + + + + + +
+
+

Ghostmaxxing · lab · ghostyle transfer

+

Lift a Ghostyle off one face, wear it on another.

+

+ Extract the paint from a workshop before / after pair and place it + on a target face — a photo, or a public-domain painting. Mark the face on + each image with a box. It runs entirely on this device. +

+ Mesh engine: loading… +
+ +
+ +
+
Beforebare face · optional
+
Drop or choose the bare workshop face
+

+ +
+ +
+
Afterpainted face
+
Drop or choose the painted workshop face
+

+ +
+ +
+
Targetreceives the paint
+
Drop or choose the target face / painting
+

+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+ + + + Load an after and a target, then draw a box on each face. +
+ +
+
The transferred result will appear here.
+
+
+

Extracted paint

+ +

The isolated Ghostyle, in a normalized face frame.

+
+
+

Mode

+

Not run yet.

+
+
+
+ +
+

+ 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 can’t find a target face, the tool uses the box you drew. Source + public-domain paintings from Wikimedia Commons or museum open-access + (Met CC0, Rijksstudio, Art Institute of Chicago, Getty Open Content). +

+
+
+ + + + From dfe2ed00ea7ee454cead4c980a0d83591f9656c3 Mon Sep 17 00:00:00 2001 From: vecna Date: Mon, 20 Jul 2026 14:31:32 +0200 Subject: [PATCH 03/11] divided ghostyle-transfer version 1 into proper files --- ghostyle-transfer.html | 716 +++++++----------------------- scripts/i18n.js | 317 ++++++++++++++ scripts/transfer.js | 912 +++++++++++++++++++++++++++++++++++++++ styles/content-pages.css | 302 +++++++++++++ 4 files changed, 1698 insertions(+), 549 deletions(-) create mode 100644 scripts/transfer.js diff --git a/ghostyle-transfer.html b/ghostyle-transfer.html index a99ae98..5c681af 100644 --- a/ghostyle-transfer.html +++ b/ghostyle-transfer.html @@ -1,558 +1,176 @@ - - - -Ghostmaxxing — Ghostyle Transfer (lab) - - - - + + + + Ghostyle Transfer — Ghostmaxxing + + + + + + + + + + + + + + - -
-
-

Ghostmaxxing · lab · ghostyle transfer

-

Lift a Ghostyle off one face, wear it on another.

-

- Extract the paint from a workshop before / after pair and place it - on a target face — a photo, or a public-domain painting. Mark the face on - each image with a box. It runs entirely on this device. -

- Mesh engine: loading… -
-
- -
-
Beforebare face · optional
-
Drop or choose the bare workshop face
-

- -
- -
-
Afterpainted face
-
Drop or choose the painted workshop face
-

- -
- -
-
Targetreceives the paint
-
Drop or choose the target face / painting
-

- -
-
- -
-
-
-
-
-
-
-
-
-
-
- -
- - - - Load an after and a target, then draw a box on each face. -
- -
-
The transferred result will appear here.
-
-
-

Extracted paint

- -

The isolated Ghostyle, in a normalized face frame.

-
-
-

Mode

-

Not run yet.

-
+ +
+
+
+ + + + Ghostmaxxing + + + A public lab for testing face-recognition camouflage + + + + +
+ +
+ Lab · Ghostyle transfer +

Lift a Ghostyle off one face, wear it on another.

+

+ Extract the paint from a workshop before / after pair and place it on a target face. +

+

+ 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. +

+ + + Mesh engine: loading… + +
+ +
+
+
+
+ Before + bare face · optional +
+
+ Drop or choose the bare workshop face +
+

+ +
+ +
+
+ After + painted face +
+
+ Drop or choose the painted workshop face +
+

+ +
+ +
+
+ Target + receives the paint +
+
+ Drop or choose the target face / painting +
+

+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + Load an after and a target, then draw a box on each face. +
+ +
+
+ The transferred result will appear here. +
+ +
+
+ +
+

+ 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. +

+
-
+ -
-

- 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 can’t find a target face, the tool uses the box you drew. Source - public-domain paintings from Wikimedia Commons or museum open-access - (Met CC0, Rijksstudio, Art Institute of Chicago, Getty Open Content). -

-
-
- - + + diff --git a/scripts/i18n.js b/scripts/i18n.js index df6fe20..10020bb 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…', diff --git a/scripts/transfer.js b/scripts/transfer.js new file mode 100644 index 0000000..04de28f --- /dev/null +++ b/scripts/transfer.js @@ -0,0 +1,912 @@ +/** + * @module transfer + * @description + * Browser-only Ghostyle transfer lab. It extracts the visual paint delta from + * an optional before image plus a painted after image, normalizes that paint + * into a canonical face frame, and composites it onto a target face. When + * face-api landmarks are available, it upgrades from box placement to + * triangle-based mesh warping. + */ +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/'; + +/** + * @typedef {{x:number, y:number}} Point + * @typedef {{x:number, y:number, w:number, h:number}} Box + * @typedef {{img:HTMLImageElement|null, box:Box|null, scale:number, dispW:number, dispH:number}} TransferSlot + */ + +/** @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 {[number, number, number]} 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/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) { From 43f8ec84348ef911dbdf318c2c99c18ded4aa825 Mon Sep 17 00:00:00 2001 From: vecna Date: Wed, 22 Jul 2026 11:05:36 +0200 Subject: [PATCH 04/11] translated loader, made compatible saved faces --- README.md | 14 +- loader.html | 50 +++--- scripts/i18n.js | 210 +++++++++++++++++++++++ scripts/loader.js | 238 +++++++++++++++++++++++--- styles/loader.css | 12 ++ tests/e2e/loader.spec.js | 56 +++++- translations/README.md | 27 +-- translations/ghostmaxxing-summary.csv | 33 ++++ translations/ghostmaxxing.pot | 215 ++++++++++++++++++++++- 9 files changed, 797 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 41c5d15..f1c4e31 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,23 @@ The project is a static web app: there is no production build step required to o Important local entry points: - [`index.html`](index.html) — public landing page with links to code, docs, coverage, Ghostyles, project context, and the reporting node. -- [`ghostati.html`](ghostati.html) — main webcam/AR application. +- [`lab.html`](lab.html) — translated main webcam/AR application. +- [`loader.html`](loader.html) — translated internal MP4 loader for repeatable 2D/3D video tests. +- [`ghostati.html`](ghostati.html) — legacy main webcam/AR application entry point. - [`ghostyles.json`](ghostyles.json) — Ghostyle manifest. - [`JSDOC_README.md`](JSDOC_README.md) — concise generated-docs overview. - [`REFERENCES.json`](REFERENCES.json) — curated technical/cultural reference set. +# Localization coverage + +Ghostmaxxing currently ships runtime translations for English, Italian, and Portuguese through [`scripts/i18n.js`](scripts/i18n.js). The translated browser pages are: + +- [`index.html`](index.html) — homepage and public navigation. +- [`lab.html`](lab.html) — main camera lab, settings, drawers, controls, status labels, and runtime logs. +- [`loader.html`](loader.html) — internal MP4 video loader, language switcher, static interface labels, model status, and loader activity logs. + +Other static pages such as [`about.html`](about.html), [`report.html`](report.html), workshop/tutorial pages, generated docs, coverage, and reference pages are still English-baseline unless they add `data-i18n*` bindings and catalog keys. + Runtime external dependencies visible from the HTML/config layer: - [Google Fonts](https://fonts.googleapis.com) / [Google Fonts static assets](https://fonts.gstatic.com) diff --git a/loader.html b/loader.html index e381ad7..823e934 100644 --- a/loader.html +++ b/loader.html @@ -4,8 +4,8 @@ - Ghostmaxxing | Video Loader - + Ghostmaxxing | Video Loader + @@ -14,36 +14,42 @@
- + Ghostmaxxing - Internal video loader + Internal video loader -
- - loading models +
+ + + + +
+ + loading models +
-

Makeup Video Test Loader

+

Makeup Video Test Loader

-
+
-
Select a local MP4 video to begin.
+
Select a local MP4 video to begin.
- diagnostic preview + diagnostic preview
-
+
00:00.000 00:00.000 @@ -51,24 +57,24 @@

Makeup Video Test Loader

-
- - +
+ +
- Faces in memory + Faces in memory 0 - Next ID + Next ID 0 - 2D threshold + 2D threshold 0.58
-
+
-

Activity log

-

Each entry is captured at the exact video timestamp where the button was pressed.

+

Activity log

+

Each entry is captured at the exact video timestamp where the button was pressed.

diff --git a/scripts/i18n.js b/scripts/i18n.js index df6fe20..3c250fa 100644 --- a/scripts/i18n.js +++ b/scripts/i18n.js @@ -1668,6 +1668,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', @@ -1844,6 +2051,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/loader.js b/scripts/loader.js index f7c14ea..0a70bbe 100644 --- a/scripts/loader.js +++ b/scripts/loader.js @@ -6,7 +6,8 @@ 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 } from './i18n.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'); @@ -20,22 +21,89 @@ 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); @@ -43,6 +111,11 @@ function formatVideoTime(seconds) { 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; @@ -55,12 +128,23 @@ function currentPosition() { }; } +/** + * 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); @@ -68,6 +152,11 @@ function updateTimelineFromVideo() { 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')]) { @@ -77,6 +166,11 @@ function syncCanvasSize() { } } +/** + * 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; @@ -99,6 +193,38 @@ function snapshotCanvas() { 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'; @@ -106,6 +232,15 @@ function appendChip(parent, 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'); @@ -122,8 +257,8 @@ function appendEntry(kind, snapshot, summary, details) { const meta = document.createElement('div'); meta.className = 'loader-entry__meta'; - appendChip(meta, 'pressed', new Date().toLocaleString()); - appendChip(meta, 'video', summary.position.label); + 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'); @@ -139,6 +274,12 @@ function appendEntry(kind, snapshot, summary, details) { 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; @@ -159,6 +300,12 @@ function summarizeFace(result) { }; } +/** + * 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) => { @@ -172,6 +319,12 @@ function get2dDistances(result) { }).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) => { @@ -186,6 +339,13 @@ function get3dDistances(embedding) { }).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; @@ -193,6 +353,12 @@ function buildOverall(faceapiSection, mediapipeSection) { 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; @@ -200,8 +366,8 @@ async function withActionLock(fn) { try { await fn(); } catch (err) { - setStatus('error', 'action failed'); - appendEntry('error', snapshotCanvas(), { + 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) }); @@ -211,19 +377,25 @@ async function withActionLock(fn) { } } +/** + * 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('record face', snapshotCanvas(), { + appendEntry(t('loader_action_record_face'), snapshotCanvas(), { position, - metrics: { result: 'no face detected' }, - }, { message: 'No 2D face-api detection was available at this frame.' }); + 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); @@ -256,17 +428,19 @@ async function recordFace() { } })); - appendEntry('record face', snapshotCanvas(), { + 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: { @@ -278,16 +452,21 @@ async function recordFace() { }); } +/** + * 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('seek face', snapshotCanvas(), { + appendEntry(t('loader_action_seek_face'), snapshotCanvas(), { position, - metrics: { result: 'no face detected' }, - }, { message: 'No 2D face-api detection was available at this frame.' }); + metrics: { result: t('loader_no_face_result') }, + }, { message: t('loader_no_2d_face_message') }); return; } @@ -325,7 +504,7 @@ async function seekFace() { } })); - appendEntry('seek face', snapshotCanvas(), { + appendEntry(t('loader_action_seek_face'), snapshotCanvas(), { position, metrics: { '2D closest': distances2d[0] ? `ID ${distances2d[0].id} / ${distances2d[0].distance.toFixed(4)}` : 'empty DB', @@ -350,8 +529,13 @@ async function seekFace() { }); } +/** + * Loads face-api model weights and the MediaPipe 3D embedder required by the loader. + * + * @returns {Promise} + */ async function loadModels() { - setStatus('init', 'loading face-api'); + setLoaderStatus('init', 'loader_status_loading_faceapi'); await Promise.all([ faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URLS.tiny), faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URLS.landmarks), @@ -359,10 +543,15 @@ async function loadModels() { faceapi.nets.ageGenderNet.loadFromUri(MODEL_URLS.ageGender), ]); - setStatus('init', 'loading 3D embedder'); + 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]; @@ -374,7 +563,7 @@ function bindVideo() { els.video.src = objectUrl; els.video.load(); els.placeholder.style.display = 'none'; - setLog(`Loaded local video: ${file.name}`, 'loader'); + setLog(t('loader_log_loaded_local_video', { name: file.name }), 'loader'); }); els.video.addEventListener('loadedmetadata', () => { @@ -404,8 +593,17 @@ function bindVideo() { 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(); @@ -419,14 +617,14 @@ async function init() { try { await loadModels(); modelsReady = true; - setStatus('live', 'ready'); - setLog('Loader ready. Select an MP4 and press Record face or Seek face.', 'loader'); + 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) { - setStatus('error', 'model load failed'); - setLog(`Loader model error: ${err.message || err}`, 'loader'); + setLoaderStatus('error', 'loader_status_model_load_failed'); + setLog(t('loader_model_error_log', { message: err.message || err }), 'loader'); } } diff --git a/styles/loader.css b/styles/loader.css index f96d857..4b19adc 100644 --- a/styles/loader.css +++ b/styles/loader.css @@ -22,6 +22,14 @@ 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; @@ -336,6 +344,10 @@ grid-template-columns: 1fr; } + .loader-header__tools { + justify-content: flex-start; + } + .loader-readout { margin-left: 0; } diff --git a/tests/e2e/loader.spec.js b/tests/e2e/loader.spec.js index ebb70a2..31f3c18 100644 --- a/tests/e2e/loader.spec.js +++ b/tests/e2e/loader.spec.js @@ -5,7 +5,9 @@ test.describe('MP4 loader tool', () => { 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) => { @@ -56,6 +58,10 @@ test.describe('MP4 loader tool', () => { 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(); }); @@ -64,7 +70,15 @@ test.describe('MP4 loader tool', () => { 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) => { @@ -79,7 +93,31 @@ test.describe('MP4 loader tool', () => { faceRecognitionNet: { loadFromUri: async () => {} }, ageGenderNet: { loadFromUri: async () => {} }, }, - detectSingleFace: () => null, + 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, }; `, @@ -145,6 +183,22 @@ test.describe('MP4 loader tool', () => { 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(); diff --git a/translations/README.md b/translations/README.md index c480309..6a194e3 100644 --- a/translations/README.md +++ b/translations/README.md @@ -25,7 +25,7 @@ Keys must stay lowercase with underscores, for example `face_saved_status`. 3. Add Italian and Portuguese translations in the same entry. 4. Add `context` with the best source reference, for example `scripts/main.js:120`. 5. Add `notes` if the string contains a technical term, product term, or ambiguous wording. -6. Replace the hardcoded UI/log/console string with `t('your_key')` in JavaScript, or add `data-i18n="your_key"` / `data-i18n-title="your_key"` / `data-i18n-aria-label="your_key"` / `data-i18n-alt="your_key"` in HTML. +6. Replace the hardcoded UI/log/console string with `t('your_key')` in JavaScript, or add `data-i18n="your_key"` / `data-i18n-title="your_key"` / `data-i18n-aria-label="your_key"` / `data-i18n-alt="your_key"` / `data-i18n-content="your_key"` in HTML. 7. Run: ```sh @@ -51,24 +51,25 @@ Keys must stay lowercase with underscores, for example `face_saved_status`. 8. Upload the refreshed `translations/ghostmaxxing.pot` to Crowdin. 9. Export/import completed Crowdin translations back into `scripts/i18n.js` or the future build pipeline. -10. Test the language selector in both `index.html` and `lab.html`. +10. Test the language selector in `index.html`, `lab.html`, and `loader.html`. ## Language Selection Persistence -The language selector is shared by the homepage and the lab. +The language selector is shared by the homepage, the lab, and the video loader. 1. `index.html` loads `scripts/home.js`. 2. `lab.html` loads the main lab JavaScript, which also initializes i18n. -3. Both pages call `setupLocaleSelect()` from `scripts/i18n.js`. -4. When a user selects a language, `setLocale()` saves the selected locale in browser `localStorage`. -5. The storage key is `ghostmaxxing-locale`, exported as `LOCALE_STORAGE_KEY` in `scripts/i18n.js`. -6. When `lab.html` opens later, `scripts/i18n.js` reads `ghostmaxxing-locale` and applies the same language. -7. If no saved language exists, Ghostmaxxing tries the browser language. -8. If the browser language is unsupported, Ghostmaxxing falls back to English. - -This means a user can choose `Italiano` on `index.html`, click into the lab, and -`lab.html` will open in Italian because both pages share the same origin-level -`localStorage` value. +3. `loader.html` loads `scripts/loader.js`, which initializes i18n for the video-loader interface. +4. All three pages call `setupLocaleSelect()` from `scripts/i18n.js`. +5. When a user selects a language, `setLocale()` saves the selected locale in browser `localStorage`. +6. The storage key is `ghostmaxxing-locale`, exported as `LOCALE_STORAGE_KEY` in `scripts/i18n.js`. +7. When `lab.html` or `loader.html` opens later, `scripts/i18n.js` reads `ghostmaxxing-locale` and applies the same language. +8. If no saved language exists, Ghostmaxxing tries the browser language. +9. If the browser language is unsupported, Ghostmaxxing falls back to English. + +This means a user can choose `Italiano` on `index.html`, click into the lab or +open the video loader, and the destination page will open in Italian because +all translated pages share the same origin-level `localStorage` value. ## Extraction Workflow diff --git a/translations/ghostmaxxing-summary.csv b/translations/ghostmaxxing-summary.csv index f4dde47..26febe2 100644 --- a/translations/ghostmaxxing-summary.csv +++ b/translations/ghostmaxxing-summary.csv @@ -259,6 +259,39 @@ "console_mediapipe_tick_error"|"scripts/mediapipe-loop.js:138"|"[mediapipe-loop] errore tick:"|"[mediapipe-loop] tick error:"|"[mediapipe-loop] erro de tick:" "console_lab_ui_bus_missing"|"scripts/lab-ui.js:380"|"[lab-ui] state.gstmxxEvents non trovato — readout/effect mirroring disabilitati"|"[lab-ui] state.gstmxxEvents not found — readout/effect mirroring disabled"|"[lab-ui] state.gstmxxEvents não encontrado — readout/espelhamento de efeito desativados" "console_auto_find_tick_error"|"scripts/auto-find-loop.js:116"|"[auto-find-loop] tick error:"|"[auto-find-loop] tick error:"|"[auto-find-loop] erro de tick:" +"loader_page_title"|"loader.html:7"|"Ghostmaxxing | Loader video"|"Ghostmaxxing | Video Loader"|"Ghostmaxxing | Carregador de vídeo" +"loader_meta_description"|"loader.html:8"|"Strumento interno Ghostmaxxing per testare video MP4 locali di makeup contro i motori facciali 2D e 3D."|"Internal Ghostmaxxing tool for testing local MP4 makeup videos against the 2D and 3D face engines."|"Ferramenta interna do Ghostmaxxing para testar vídeos MP4 locais de maquiagem contra os motores faciais 2D e 3D." +"loader_wordmark_label"|"loader.html:19"|"Homepage Ghostmaxxing"|"Ghostmaxxing homepage"|"Página inicial Ghostmaxxing" +"loader_subtitle"|"loader.html:24"|"Loader video interno"|"Internal video loader"|"Carregador interno de vídeo" +"loader_loading_models_status"|"loader.html:32"|"caricamento modelli"|"loading models"|"carregando modelos" +"loader_title"|"loader.html:37"|"Loader di test video makeup"|"Makeup Video Test Loader"|"Carregador de teste de vídeo de maquiagem" +"loader_stage_label"|"loader.html:39"|"Carica e riproduci video"|"Load and play video"|"Carregar e reproduzir vídeo" +"loader_placeholder"|"loader.html:41"|"Seleziona un video MP4 locale per iniziare."|"Select a local MP4 video to begin."|"Selecione um vídeo MP4 local para começar." +"loader_load_mp4"|"loader.html:50"|"Carica MP4"|"Load MP4"|"Carregar MP4" +"loader_timeline_label"|"loader.html:53"|"Avanzamento video"|"Video progress"|"Progresso do vídeo" +"loader_actions_label"|"loader.html:63"|"Azioni volto"|"Face actions"|"Ações de rosto" +"loader_record_face_button"|"loader.html:64; scripts/loader.js:250"|"Registra volto"|"Record face"|"Gravar rosto" +"loader_seek_face_button"|"loader.html:65; scripts/loader.js:303"|"Cerca volto"|"Seek face"|"Buscar rosto" +"loader_faces_in_memory"|"loader.html:67"|"Volti in memoria"|"Faces in memory"|"Rostos na memória" +"loader_2d_threshold"|"loader.html:71"|"Soglia 2D"|"2D threshold"|"Limiar 2D" +"loader_log_label"|"loader.html:78"|"Registro attività pulsanti"|"Button activity log"|"Registro de atividade dos botões" +"loader_activity_log_title"|"loader.html:80"|"Registro attività"|"Activity log"|"Registro de atividade" +"loader_activity_log_description"|"loader.html:81"|"Ogni voce viene catturata al timestamp video esatto in cui è stato premuto il pulsante."|"Each entry is captured at the exact video timestamp where the button was pressed."|"Cada entrada é capturada no timestamp exato do vídeo em que o botão foi pressionado." +"loader_status_action_failed"|"scripts/loader.js:222"|"azione fallita"|"action failed"|"ação falhou" +"loader_status_loading_faceapi"|"scripts/loader.js:396"|"caricamento face-api"|"loading face-api"|"carregando face-api" +"loader_status_loading_3d_embedder"|"scripts/loader.js:404"|"caricamento embedder 3D"|"loading 3D embedder"|"carregando embedder 3D" +"loader_status_ready"|"scripts/loader.js:474"|"pronto"|"ready"|"pronto" +"loader_status_model_load_failed"|"scripts/loader.js:480"|"caricamento modelli fallito"|"model load failed"|"falha ao carregar modelos" +"loader_log_loaded_local_video"|"scripts/loader.js:428"|"Video locale caricato: {name}"|"Loaded local video: {name}"|"Vídeo local carregado: {name}" +"loader_ready_log"|"scripts/loader.js:475"|"Loader pronto. Seleziona un MP4 e premi Registra volto o Cerca volto."|"Loader ready. Select an MP4 and press Record face or Seek face."|"Carregador pronto. Selecione um MP4 e pressione Gravar rosto ou Buscar rosto." +"loader_model_error_log"|"scripts/loader.js:481"|"Errore modelli loader: {message}"|"Loader model error: {message}"|"Erro de modelo do carregador: {message}" +"loader_action_error"|"scripts/loader.js:223"|"errore"|"error"|"erro" +"loader_action_record_face"|"scripts/loader.js:231; scripts/loader.js:250"|"registra volto"|"record face"|"gravar rosto" +"loader_action_seek_face"|"scripts/loader.js:286; scripts/loader.js:303"|"cerca volto"|"seek face"|"buscar rosto" +"loader_chip_pressed"|"scripts/loader.js:146"|"premuto"|"pressed"|"pressionado" +"loader_chip_video"|"scripts/loader.js:147"|"video"|"video"|"vídeo" +"loader_no_face_result"|"scripts/loader.js:233; scripts/loader.js:288"|"nessun volto rilevato"|"no face detected"|"nenhum rosto detectado" +"loader_no_2d_face_message"|"scripts/loader.js:234; scripts/loader.js:289"|"Nessuna rilevazione 2D face-api era disponibile in questo frame."|"No 2D face-api detection was available at this frame."|"Nenhuma detecção 2D do face-api estava disponível neste frame." "homepage_wordmark_label"|"index.html:27"|"Prototipo homepage Ghostmaxxing"|"Ghostmaxxing homepage prototype"|"Protótipo da página inicial Ghostmaxxing" "homepage_subtitle"|"index.html:32"|"Un laboratorio pubblico per testare il camouflage contro il riconoscimento facciale"|"A public lab for testing face-recognition camouflage"|"Um laboratório público para testar camuflagem contra reconhecimento facial" "prototype_navigation_label"|"index.html:36"|"Navigazione del prototipo"|"Prototype navigation"|"Navegação do protótipo" diff --git a/translations/ghostmaxxing.pot b/translations/ghostmaxxing.pot index 917cb23..f972354 100644 --- a/translations/ghostmaxxing.pot +++ b/translations/ghostmaxxing.pot @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: Ghostmaxxing 0.1.0\n" -"POT-Creation-Date: 2026-07-08\n" +"POT-Creation-Date: 2026-07-22\n" "Language-Team: NotYetExisting \n" "Content-Type: text/plain; charset=UTF-8\n" @@ -1704,6 +1704,219 @@ msgstr "" msgid "[auto-find-loop] tick error:" msgstr "" +#. key: loader_page_title +#. it: Ghostmaxxing | Loader video +#: loader.html:7 +msgid "Ghostmaxxing | Video Loader" +msgstr "" + +#. Glossary: MP4, face engines, 2D, 3D. +#. key: loader_meta_description +#. it: Strumento interno Ghostmaxxing per testare video MP4 locali di makeup contro i motori facciali 2D e 3D. +#: loader.html:8 +msgid "Internal Ghostmaxxing tool for testing local MP4 makeup videos against the 2D and 3D face engines." +msgstr "" + +#. key: loader_wordmark_label +#. it: Homepage Ghostmaxxing +#: loader.html:19 +msgid "Ghostmaxxing homepage" +msgstr "" + +#. key: loader_subtitle +#. it: Loader video interno +#: loader.html:24 +msgid "Internal video loader" +msgstr "" + +#. key: loader_loading_models_status +#. it: caricamento modelli +#: loader.html:32 +msgid "loading models" +msgstr "" + +#. key: loader_title +#. it: Loader di test video makeup +#: loader.html:37 +msgid "Makeup Video Test Loader" +msgstr "" + +#. key: loader_stage_label +#. it: Carica e riproduci video +#: loader.html:39 +msgid "Load and play video" +msgstr "" + +#. Glossary: MP4. +#. key: loader_placeholder +#. it: Seleziona un video MP4 locale per iniziare. +#: loader.html:41 +msgid "Select a local MP4 video to begin." +msgstr "" + +#. Glossary: MP4. +#. key: loader_load_mp4 +#. it: Carica MP4 +#: loader.html:50 +msgid "Load MP4" +msgstr "" + +#. key: loader_timeline_label +#. it: Avanzamento video +#: loader.html:53 +msgid "Video progress" +msgstr "" + +#. key: loader_actions_label +#. it: Azioni volto +#: loader.html:63 +msgid "Face actions" +msgstr "" + +#. key: loader_record_face_button +#. it: Registra volto +#: loader.html:64 +#: scripts/loader.js:250 +msgid "Record face" +msgstr "" + +#. key: loader_seek_face_button +#. it: Cerca volto +#: loader.html:65 +#: scripts/loader.js:303 +msgid "Seek face" +msgstr "" + +#. key: loader_faces_in_memory +#. it: Volti in memoria +#: loader.html:67 +msgid "Faces in memory" +msgstr "" + +#. Glossary: threshold, 2D. +#. key: loader_2d_threshold +#. it: Soglia 2D +#: loader.html:71 +msgid "2D threshold" +msgstr "" + +#. key: loader_log_label +#. it: Registro attività pulsanti +#: loader.html:78 +msgid "Button activity log" +msgstr "" + +#. key: loader_activity_log_title +#. it: Registro attività +#: loader.html:80 +msgid "Activity log" +msgstr "" + +#. Glossary: timestamp. +#. key: loader_activity_log_description +#. it: Ogni voce viene catturata al timestamp video esatto in cui è stato premuto il pulsante. +#: loader.html:81 +msgid "Each entry is captured at the exact video timestamp where the button was pressed." +msgstr "" + +#. key: loader_status_action_failed +#. it: azione fallita +#: scripts/loader.js:222 +msgid "action failed" +msgstr "" + +#. Glossary: face-api. +#. key: loader_status_loading_faceapi +#. it: caricamento face-api +#: scripts/loader.js:396 +msgid "loading face-api" +msgstr "" + +#. Glossary: 3D embedder. +#. key: loader_status_loading_3d_embedder +#. it: caricamento embedder 3D +#: scripts/loader.js:404 +msgid "loading 3D embedder" +msgstr "" + +#. key: loader_status_ready +#. it: pronto +#: scripts/loader.js:474 +msgid "ready" +msgstr "" + +#. key: loader_status_model_load_failed +#. it: caricamento modelli fallito +#: scripts/loader.js:480 +msgid "model load failed" +msgstr "" + +#. key: loader_log_loaded_local_video +#. it: Video locale caricato: {name} +#: scripts/loader.js:428 +msgid "Loaded local video: {name}" +msgstr "" + +#. Glossary: MP4. +#. key: loader_ready_log +#. it: Loader pronto. Seleziona un MP4 e premi Registra volto o Cerca volto. +#: scripts/loader.js:475 +msgid "Loader ready. Select an MP4 and press Record face or Seek face." +msgstr "" + +#. key: loader_model_error_log +#. it: Errore modelli loader: {message} +#: scripts/loader.js:481 +msgid "Loader model error: {message}" +msgstr "" + +#. key: loader_action_error +#. it: errore +#: scripts/loader.js:223 +msgid "error" +msgstr "" + +#. key: loader_action_record_face +#. it: registra volto +#: scripts/loader.js:231 +#: scripts/loader.js:250 +msgid "record face" +msgstr "" + +#. key: loader_action_seek_face +#. it: cerca volto +#: scripts/loader.js:286 +#: scripts/loader.js:303 +msgid "seek face" +msgstr "" + +#. key: loader_chip_pressed +#. it: premuto +#: scripts/loader.js:146 +msgid "pressed" +msgstr "" + +#. key: loader_chip_video +#. it: video +#: scripts/loader.js:147 +msgid "video" +msgstr "" + +#. key: loader_no_face_result +#. it: nessun volto rilevato +#: scripts/loader.js:233 +#: scripts/loader.js:288 +msgid "no face detected" +msgstr "" + +#. Glossary: 2D, face-api, frame. +#. key: loader_no_2d_face_message +#. it: Nessuna rilevazione 2D face-api era disponibile in questo frame. +#: scripts/loader.js:234 +#: scripts/loader.js:289 +msgid "No 2D face-api detection was available at this frame." +msgstr "" + #. key: homepage_wordmark_label #. it: Prototipo homepage Ghostmaxxing #: index.html:27 From a689252fca138db1be27d0b0b9370a3b36183696 Mon Sep 17 00:00:00 2001 From: vecna Date: Wed, 22 Jul 2026 11:17:57 +0200 Subject: [PATCH 05/11] updated home page and about and sitemap with new internal tools --- about.html | 24 ++++++++++++++++++++++++ index.html | 4 ++++ scripts/i18n.js | 18 ++++++++++++++++++ sitemap.xml | 15 +++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/about.html b/about.html index d459e00..82adee7 100644 --- a/about.html +++ b/about.html @@ -207,6 +207,21 @@

A ban that keeps getting reopened.

+
+

Internal tools & diagnostics.

+

+ To support deeper analysis, model testing, and pattern sharing, Ghostmaxxing includes experimental diagnostic tools. These utilities run entirely locally in the browser to examine recorded behavior and transfer designs. +

+

Video Loader

+

+ The Video Loader allows users to import local MP4 video files to run against our on-device 2D and 3D face-detection engines. This allows for precise frame-by-frame analysis, face extraction, database recording, and signature comparison without requiring a live camera feed. It is designed to evaluate recorded workshop outcomes or diagnostic video tracks under stable, repeatable conditions. +

+

Ghostyle Transfer

+

+ The Ghostyle Transfer tool extracts painted makeup patterns from a workshop before/after image pair and attaches them to a new target face. When the local face engine detects matching landmarks, the tool aligns the pattern using a 3D face mesh. Otherwise, it scales the pattern using manual bounding boxes, letting researchers visual-test camouflage layouts on different facial structures before physical application. +

+
+

Read, test, report.

@@ -219,6 +234,14 @@

Read, test, report.

Open the lab

Run local browser tests and watch a recognition pipeline react to your face in real time.

+ +

Video Loader

+

Run pre-recorded MP4 video tests to extract and compare face signatures frame-by-frame.

+
+ +

Ghostyle Transfer

+

Extract makeup patterns from before/after images and project them onto new target faces.

+

Research references

Trace the artistic, technical, and activist lineage of face-obfuscation work.

@@ -243,6 +266,7 @@

On this page

Origin Research question Why leaking matters + Internal tools Resources diff --git a/index.html b/index.html index 3a7fcaa..b1da7b6 100644 --- a/index.html +++ b/index.html @@ -56,6 +56,10 @@

+
Technical documentation Code diff --git a/scripts/i18n.js b/scripts/i18n.js index 16a4970..133b6ff 100644 --- a/scripts/i18n.js +++ b/scripts/i18n.js @@ -2275,6 +2275,24 @@ 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', + }, homepage_hero_alt: { context: 'index.html:65', it: 'Ritratto diviso tra overlay di riconoscimento facciale e camouflage avversario.', diff --git a/sitemap.xml b/sitemap.xml index 08c7005..4899026 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -67,4 +67,19 @@ 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 + From dc739bc1f1883967401d169e1fde13375c0ead20 Mon Sep 17 00:00:00 2001 From: vecna Date: Wed, 22 Jul 2026 12:04:38 +0200 Subject: [PATCH 06/11] first iteration realtime change monitor --- realtime.html | 50 ++++ scripts/realtime.js | 487 +++++++++++++++++++++++++++++++++++++ styles/realtime.css | 227 +++++++++++++++++ tests/e2e/realtime.spec.js | 31 +++ 4 files changed, 795 insertions(+) create mode 100644 realtime.html create mode 100644 scripts/realtime.js create mode 100644 styles/realtime.css create mode 100644 tests/e2e/realtime.spec.js diff --git a/realtime.html b/realtime.html new file mode 100644 index 0000000..45fb22a --- /dev/null +++ b/realtime.html @@ -0,0 +1,50 @@ + + + + + + + Ghostmaxxing | Latent Space Visualizer + + + + + + +
+
+
+

Distance

+

0.000

+
+ +

Threshold: 0.6

+
+ +
+ +
+
Loading models...
+
+
3
+ +
+
+ +
+
+

Descriptor Delta

+

CALIBRATING

+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/scripts/realtime.js b/scripts/realtime.js new file mode 100644 index 0000000..4781437 --- /dev/null +++ b/scripts/realtime.js @@ -0,0 +1,487 @@ +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; + +/** + * 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 in the left canvas panel. + * + * @returns {void} + */ +function drawGraphCanvas() { + if (!graphCtx || !els.graph) return; + + const width = els.graph.width; + const height = els.graph.height; + + graphCtx.clearRect(0, 0, width, height); + + graphCtx.fillStyle = '#070b10'; + graphCtx.fillRect(0, 0, width, height); + + const thresholdY = height - Math.min((THRESHOLD / 1.0) * height, height); + graphCtx.strokeStyle = 'rgba(255, 81, 56, 0.8)'; + graphCtx.lineWidth = 2; + graphCtx.beginPath(); + graphCtx.moveTo(0, thresholdY); + graphCtx.lineTo(width, thresholdY); + 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 = '#8be0ff'; + 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 = (i / span) * width; + const y = height - (normalized * 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/styles/realtime.css b/styles/realtime.css new file mode 100644 index 0000000..2a83e81 --- /dev/null +++ b/styles/realtime.css @@ -0,0 +1,227 @@ +:root { + --bg-1: #060708; + --bg-2: #11161c; + --panel: rgba(8, 11, 15, 0.82); + --panel-edge: rgba(150, 170, 196, 0.23); + --text-main: #eef5ff; + --text-faint: #91a3b8; + --grid-off: #1f2730; + --low: #2de48b; + --med: #ffcc33; + --high: #ff5138; + --line: #8be0ff; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + color: var(--text-main); + font-family: "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace; + background: radial-gradient(circle at 20% 10%, #182534 0%, var(--bg-1) 44%, #020304 100%); + overflow: hidden; +} + +#ui-layer { + width: 100vw; + height: 100vh; + display: grid; + grid-template-columns: 240px 1fr 240px; + gap: 16px; + padding: 16px; +} + +#graph-container, +#embedding-panel { + display: flex; + flex-direction: column; + border: 1px solid var(--panel-edge); + border-radius: 12px; + background: var(--panel); + backdrop-filter: blur(8px); + min-height: 0; +} + +.panel-header { + border-bottom: 1px solid var(--panel-edge); + padding: 12px; +} + +.panel-title, +.panel-value, +.panel-note { + margin: 0; +} + +.panel-title { + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + font-size: 12px; +} + +.panel-value { + margin-top: 8px; + font-size: 16px; + font-weight: 700; +} + +.panel-note { + padding: 10px 12px; + border-top: 1px solid var(--panel-edge); + color: var(--text-faint); + font-size: 12px; +} + +#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(--panel-edge); +} + +#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(4, 6, 9, 0.45) 0%, rgba(4, 6, 9, 0.05) 40%, rgba(4, 6, 9, 0.6) 100%), + repeating-linear-gradient( + to right, + rgba(139, 224, 255, 0.04) 0, + rgba(139, 224, 255, 0.04) 1px, + transparent 1px, + transparent 12px + ); + pointer-events: none; +} + +#status-pill { + position: absolute; + top: 12px; + left: 12px; + background: rgba(3, 13, 20, 0.88); + border: 1px solid var(--panel-edge); + border-radius: 999px; + padding: 7px 12px; + font-size: 12px; + letter-spacing: 0.04em; +} + +#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 rgba(238, 245, 255, 0.9); + background: rgba(6, 7, 8, 0.6); + font-size: 36px; + font-weight: 700; +} + +#countdown-circle.waiting { + width: auto; + min-height: 52px; + height: auto; + padding: 8px 16px; + border-radius: 999px; + font-size: 14px; +} + +#btn-reset { + padding: 10px 18px; + border-radius: 999px; + border: 1px solid rgba(185, 215, 255, 0.45); + color: var(--text-main); + background: linear-gradient(180deg, rgba(90, 155, 228, 0.45), rgba(35, 72, 108, 0.68)); + font: inherit; + font-size: 14px; + cursor: pointer; +} + +#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(--grid-off); +} + +.cell.on-low { + background: var(--low); +} + +.cell.on-med { + background: var(--med); +} + +.cell.on-high { + background: var(--high); +} + +@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/realtime.spec.js b/tests/e2e/realtime.spec.js new file mode 100644 index 0000000..6b6d7d3 --- /dev/null +++ b/tests/e2e/realtime.spec.js @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Realtime page', () => { + test('loads realtime html, css, and js without 404 responses', async ({ page }) => { + const targetPaths = new Set(['/realtime.html', '/styles/realtime.css', '/scripts/realtime.js']); + const seen = new Set(); + const failing = []; + + page.on('response', async (response) => { + const url = new URL(response.url()); + if (!targetPaths.has(url.pathname)) return; + seen.add(url.pathname); + if (response.status() === 404) { + failing.push({ path: url.pathname, status: response.status() }); + } + }); + + await page.goto('/realtime.html'); + + await expect.poll(() => Array.from(seen).sort()).toEqual(Array.from(targetPaths).sort()); + expect(failing).toEqual([]); + + await expect(page.locator('#ui-layer')).toBeVisible(); + await expect(page.locator('#embedding-bar .descriptor-row')).toHaveCount(128); + + const hasRuntimeBridge = await page.evaluate(() => { + return Boolean(window.gstmxxRealtime && typeof window.gstmxxRealtime.getState === 'function'); + }); + expect(hasRuntimeBridge).toBeTruthy(); + }); +}); From d30e0efced7d242f9352ff1f8f008f6a21590bd9 Mon Sep 17 00:00:00 2001 From: vecna Date: Wed, 22 Jul 2026 12:23:54 +0200 Subject: [PATCH 07/11] added the Lantent Space Realtime Visualizer --- about.html | 8 +++ index.html | 1 + realtime.html | 13 +++++ scripts/i18n.js | 6 +++ scripts/realtime.js | 34 ++++++++---- sitemap.xml | 7 +++ styles/realtime.css | 127 ++++++++++++++++++++++++-------------------- 7 files changed, 128 insertions(+), 68 deletions(-) diff --git a/about.html b/about.html index 82adee7..971e4df 100644 --- a/about.html +++ b/about.html @@ -220,6 +220,10 @@

Ghostyle Transfer

The Ghostyle Transfer tool extracts painted makeup patterns from a workshop before/after image pair and attaches them to a new target face. When the local face engine detects matching landmarks, the tool aligns the pattern using a 3D face mesh. Otherwise, it scales the pattern using manual bounding boxes, letting researchers visual-test camouflage layouts on different facial structures before physical application.

+

Latent Space Visualizer

+

+ The Latent Space Visualizer is a real-time visual debugger for analyzing face descriptor drift. By recording a baseline face signature via webcam, it tracks facial embedding alterations and showcases biometric distance thresholds alongside a 128-dimensional descriptor equalizer. It is used to study exactly how specific facial movements, lighting shifts, and makeup strokes skew facial signatures. +

@@ -242,6 +246,10 @@

Video Loader

Ghostyle Transfer

Extract makeup patterns from before/after images and project them onto new target faces.

+ +

Latent Space Visualizer

+

Debug biometric distance thresholds and visualize face descriptor drift in real time.

+

Research references

Trace the artistic, technical, and activist lineage of face-obfuscation work.

diff --git a/index.html b/index.html index b1da7b6..4baff5a 100644 --- a/index.html +++ b/index.html @@ -59,6 +59,7 @@

Technical documentation diff --git a/realtime.html b/realtime.html index 45fb22a..69ce2c7 100644 --- a/realtime.html +++ b/realtime.html @@ -10,11 +10,24 @@ content="Real-time visual debugger for face descriptor drift, equalizer deltas, and biometric distance thresholds." /> +
+ + + +

Distance

diff --git a/scripts/i18n.js b/scripts/i18n.js index 133b6ff..3e52df8 100644 --- a/scripts/i18n.js +++ b/scripts/i18n.js @@ -2293,6 +2293,12 @@ export const messages = { 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.', diff --git a/scripts/realtime.js b/scripts/realtime.js index 4781437..d04c2cc 100644 --- a/scripts/realtime.js +++ b/scripts/realtime.js @@ -39,6 +39,19 @@ const els = { 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. * @@ -128,27 +141,30 @@ function initEqualizer() { } /** - * Draws the smoothed distance history in the left canvas panel. + * 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 = '#070b10'; + graphCtx.fillStyle = palette.background; graphCtx.fillRect(0, 0, width, height); - const thresholdY = height - Math.min((THRESHOLD / 1.0) * height, height); - graphCtx.strokeStyle = 'rgba(255, 81, 56, 0.8)'; + const thresholdX = Math.min((THRESHOLD / 1.0) * width, width); + graphCtx.strokeStyle = palette.threshold; graphCtx.lineWidth = 2; graphCtx.beginPath(); - graphCtx.moveTo(0, thresholdY); - graphCtx.lineTo(width, thresholdY); + graphCtx.moveTo(thresholdX, 0); + graphCtx.lineTo(thresholdX, height); graphCtx.stroke(); if (!runtime.graphHistory.length) return; @@ -158,15 +174,15 @@ function drawGraphCanvas() { const start = points.length - maxVisible; const span = Math.max(maxVisible - 1, 1); - graphCtx.strokeStyle = '#8be0ff'; + 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 = (i / span) * width; - const y = height - (normalized * height); + const x = normalized * width; + const y = (i / span) * height; if (i === 0) graphCtx.moveTo(x, y); else graphCtx.lineTo(x, y); diff --git a/sitemap.xml b/sitemap.xml index 4899026..b6edfd4 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -82,4 +82,11 @@ monthly 0.50 + + + https://sindacato.nina.watch/ghostati/realtime.html + 2026-07-22 + monthly + 0.50 + diff --git a/styles/realtime.css b/styles/realtime.css index 2a83e81..51a46df 100644 --- a/styles/realtime.css +++ b/styles/realtime.css @@ -1,34 +1,8 @@ -:root { - --bg-1: #060708; - --bg-2: #11161c; - --panel: rgba(8, 11, 15, 0.82); - --panel-edge: rgba(150, 170, 196, 0.23); - --text-main: #eef5ff; - --text-faint: #91a3b8; - --grid-off: #1f2730; - --low: #2de48b; - --med: #ffcc33; - --high: #ff5138; - --line: #8be0ff; -} - -* { +#ui-layer, +#ui-layer * { box-sizing: border-box; } -html, -body { - margin: 0; - height: 100%; -} - -body { - color: var(--text-main); - font-family: "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace; - background: radial-gradient(circle at 20% 10%, #182534 0%, var(--bg-1) 44%, #020304 100%); - overflow: hidden; -} - #ui-layer { width: 100vw; height: 100vh; @@ -38,19 +12,26 @@ body { 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(--panel-edge); + border: 1px solid var(--line-soft); border-radius: 12px; - background: var(--panel); + background: rgba(12, 9, 6, 0.52); backdrop-filter: blur(8px); min-height: 0; } .panel-header { - border-bottom: 1px solid var(--panel-edge); + border-bottom: 1px solid var(--line-soft); padding: 12px; } @@ -61,23 +42,24 @@ body { } .panel-title { + font: 700 10px var(--mono); text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-faint); - font-size: 12px; + letter-spacing: 0.12em; + color: var(--faint); } .panel-value { margin-top: 8px; - font-size: 16px; - font-weight: 700; + font: 400 22px/1 var(--serif); + color: var(--yellow); + font-variant-numeric: tabular-nums; } .panel-note { padding: 10px 12px; - border-top: 1px solid var(--panel-edge); - color: var(--text-faint); - font-size: 12px; + border-top: 1px solid var(--line-soft); + color: var(--muted); + font: 11px var(--mono); } #distance-graph { @@ -91,7 +73,7 @@ body { position: relative; border-radius: 14px; overflow: hidden; - border: 1px solid var(--panel-edge); + border: 1px solid var(--line); } #webcam { @@ -106,11 +88,11 @@ body { position: absolute; inset: 0; background: - linear-gradient(to bottom, rgba(4, 6, 9, 0.45) 0%, rgba(4, 6, 9, 0.05) 40%, rgba(4, 6, 9, 0.6) 100%), + 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(139, 224, 255, 0.04) 0, - rgba(139, 224, 255, 0.04) 1px, + rgba(255, 231, 168, 0.04) 0, + rgba(255, 231, 168, 0.04) 1px, transparent 1px, transparent 12px ); @@ -121,12 +103,14 @@ body { position: absolute; top: 12px; left: 12px; - background: rgba(3, 13, 20, 0.88); - border: 1px solid var(--panel-edge); + background: rgba(12, 9, 6, 0.6); + border: 1px solid var(--line); border-radius: 999px; padding: 7px 12px; - font-size: 12px; + color: var(--muted); + font: 11px var(--mono); letter-spacing: 0.04em; + backdrop-filter: blur(8px); } #calibration-ui { @@ -146,10 +130,12 @@ body { display: none; align-items: center; justify-content: center; - border: 3px solid rgba(238, 245, 255, 0.9); - background: rgba(6, 7, 8, 0.6); + 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 { @@ -157,19 +143,42 @@ body { min-height: 52px; height: auto; padding: 8px 16px; + border: 1px solid var(--line); + color: var(--muted); border-radius: 999px; - font-size: 14px; + font: 12px var(--mono); } #btn-reset { - padding: 10px 18px; + 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 rgba(185, 215, 255, 0.45); - color: var(--text-main); - background: linear-gradient(180deg, rgba(90, 155, 228, 0.45), rgba(35, 72, 108, 0.68)); - font: inherit; - font-size: 14px; + 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 { @@ -189,19 +198,19 @@ body { } .cell { - background: var(--grid-off); + background: var(--panel-2); } .cell.on-low { - background: var(--low); + background: var(--dev); } .cell.on-med { - background: var(--med); + background: var(--yellow); } .cell.on-high { - background: var(--high); + background: var(--orange); } @media (max-width: 1050px) { From 5ce983506987dde90e78a9dde56c6c3a988f5b94 Mon Sep 17 00:00:00 2001 From: vecna Date: Wed, 22 Jul 2026 14:45:07 +0200 Subject: [PATCH 08/11] added Consent and Ownership --- lab.html | 29 +- scripts/camera.js | 57 ++-- scripts/config.js | 16 +- scripts/i18n.js | 192 +++++++++++++ scripts/lab-ui.js | 11 +- scripts/upload-consent.js | 425 +++++++++++++++++++++++++++++ styles/lab.css | 53 ++++ tests/unit/upload-consent.test.js | 35 +++ tutorials/consent-and-ownership.md | 194 +++++++++++++ 9 files changed, 964 insertions(+), 48 deletions(-) create mode 100644 scripts/upload-consent.js create mode 100644 tests/unit/upload-consent.test.js create mode 100644 tutorials/consent-and-ownership.md 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.

+ +

+
+
+

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/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 3e52df8..e491ea5 100644 --- a/scripts/i18n.js +++ b/scripts/i18n.js @@ -767,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', @@ -1166,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...', 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/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/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/tests/unit/upload-consent.test.js b/tests/unit/upload-consent.test.js new file mode 100644 index 0000000..172a9d9 --- /dev/null +++ b/tests/unit/upload-consent.test.js @@ -0,0 +1,35 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + createReceiptCode, + loadUploadReceipts, + persistUploadReceipts, + privateReceiptUrl, + publicUrlForReceipt, + RECEIPTS_STORAGE_KEY, +} from '../../scripts/upload-consent.js'; + +describe('upload-consent helpers', () => { + beforeEach(() => { + localStorage.removeItem(RECEIPTS_STORAGE_KEY); + window.history.replaceState({}, '', '/lab.html'); + }); + + it('creates human receipt codes without exposing the delete token', () => { + expect(createReceiptCode()).toMatch(/^GSTMXX-[2-9A-Z]{4}-[2-9A-Z]{4}-[2-9A-Z]{4}-[2-9A-Z]{4}$/); + }); + + it('builds public and private receipt URLs from upload id and token', () => { + expect(publicUrlForReceipt('upload-1')).toBe('http://localhost:3000/videos/upload-1.mp4'); + expect(publicUrlForReceipt('clip-1', 'clipboard')).toBe('http://localhost:3000/clipboard/clip-1.png'); + expect(privateReceiptUrl('upload-1', 'secret-token')).toBe('http://localhost:3000/lab.html#receipt=upload-1.secret-token'); + }); + + it('persists receipt logs locally', () => { + const receipts = [{ uploadId: 'u1', deleteToken: 't1', status: 'pending' }]; + + persistUploadReceipts(receipts); + + expect(JSON.parse(localStorage.getItem(RECEIPTS_STORAGE_KEY))).toEqual(receipts); + expect(loadUploadReceipts()).toEqual(receipts); + }); +}); diff --git a/tutorials/consent-and-ownership.md b/tutorials/consent-and-ownership.md new file mode 100644 index 0000000..1cce80b --- /dev/null +++ b/tutorials/consent-and-ownership.md @@ -0,0 +1,194 @@ +# Consent And Ownership + +**Project:** Ghostmaxxing +**Audience:** maintainers implementing or reviewing the upload flow +**Status:** July 2026 client contract for browser-managed recording, consent, receipts, and revocation + +## 1. Purpose + +The upload flow is not a generic file picker. The browser records a short clip, +keeps the resulting video `Blob` local, asks for explicit consent, and only then +posts the clip to the backend moderation queue. + +The ownership object is the receipt. A receipt gives the user: + +- the upload id assigned by the backend; +- the future public URL candidate, if moderation approves publication; +- the private delete token needed to revoke the upload; +- a copyable private receipt URL that can restore the receipt on this browser. + +The MP4 is a real-world painted-face recording. If a Ghostyle is active in the +lab while the clip is recorded, the Ghostyle id is submitted as context only. +The current recorder does not bake AR overlays into the MP4. + +## 2. Browser Sequence + +```text +Record button + -> MediaRecorder starts on the webcam MediaStream + -> MediaRecorder stops after RECORDING_CONFIG.durationMs + -> chunks become a video Blob + -> camera.js dispatches gstmxxEvents "clipRecorded" + -> upload-consent.js stores the Blob in the in-memory queue + -> upload panel shows preview, metadata, note, and consent checkbox + -> explicit consent enables "Submit to moderation" + -> FormData POST /api/uploads + -> backend returns { ok, uploadId, deleteToken } + -> client writes a durable receipt log entry +``` + +`FileReader` is not part of the normal video path. The app uploads the binary +`Blob` directly with `FormData.append('video', blob, filename)`. `FileReader` +would only be useful if the UI needed a base64 data URL, which is unnecessary +and less efficient for video. + +## 3. Local-First Boundary + +Before consent, the video is only a browser object: + +```js +const blob = new Blob(chunks, { type: mimeType }); +const previewUrl = URL.createObjectURL(blob); +``` + +The preview URL is local to the page session. It is not a public URL and does +not send the clip over the network. The current implementation keeps pending +clips in memory; reloading the page drops unsubmitted clips. Durable storage is +reserved for receipts, because those are the long-lived ownership records. + +## 4. Upload Payload + +The backend accepts multipart uploads at: + +```text +POST /api/uploads +``` + +The client sends: + +```text +video Blob, field name required by multer +kind "video" +consent_version "2026-07-v1" +app_version current client version +ghostyle_id optional active Ghostyle id +user_note optional moderator note +metrics_json optional compact local match metrics +``` + +`consent_version` is a hard gate. The backend rejects uploads that omit it. The +client constant must therefore move in lockstep with any consent-copy revision. + +## 5. Consent Copy + +Consent must describe the real behavior: + +- a painted-face video clip is sent to the Ghostmaxxing server; +- the server stores it in a pending moderation queue; +- a human moderator may approve or reject it; +- approved uploads may become reachable on the web, in feeds, and over + ActivityPub; +- the private receipt token can ask the origin server to delete the upload; +- remote copies, caches, screenshots, or federated copies may outlive origin + deletion. + +Do not say that the browser uploads an AR-composited result unless the recorder +is changed to capture a canvas stream. + +## 6. Receipt Model + +The backend response is: + +```json +{ + "ok": true, + "uploadId": "uuid", + "deleteToken": "opaque-secret" +} +``` + +The client turns that into a receipt: + +```json +{ + "receiptCode": "GSTMXX-XXXX-XXXX-XXXX-XXXX", + "uploadId": "uuid", + "deleteToken": "opaque-secret", + "publicUrl": "https://host/videos/uuid.mp4", + "privateUrl": "https://host/lab.html#receipt=uuid.deleteToken", + "status": "pending", + "consentVersion": "2026-07-v1", + "createdAt": "ISO timestamp" +} +``` + +The human receipt code is for display and support. It is not the delete secret. +The backend-issued `deleteToken` is the actual revocation credential. + +The private receipt URL uses the URL hash so the token is read by client-side +JavaScript and is not sent to the server as part of the normal HTTP request. +It is still sensitive: anyone with the private receipt URL can revoke that +upload. + +## 7. Revocation + +The client revokes an upload with: + +```text +DELETE /api/uploads/:id +X-Delete-Token: +``` + +Expected states: + +- `200`: upload deleted from the origin server; +- `403`: token mismatch; +- `404`: unknown upload id; +- `410`: upload was already deleted or rejected; +- network or `5xx`: keep the receipt and show a retryable failure. + +After a successful revocation, the receipt remains in the local log with a +deleted status. It should not disappear silently, because it is the user's +record that revocation was requested from this browser. + +## 8. Why Ghostyle Metadata Exists + +`ghostyle_id` is not consent. It is experiment context. + +If a user records a clip while `smokey-eyes` or `brush` is active in the lab, +the backend can store that id so moderators and public feeds understand what +the session was testing. This helps later analysis group clips by technique. + +For the current raw-camera recorder, `ghostyle_id` must be interpreted as: + +```text +"This Ghostyle was active in the lab while the real painted-face clip was recorded." +``` + +It must not be interpreted as: + +```text +"This MP4 contains the Ghostyle overlay." +``` + +## 9. Implementation Entry Points + +- `scripts/camera.js`: records the webcam stream and emits `clipRecorded`. +- `scripts/upload-consent.js`: owns local clip queue, consent gate, upload, + receipt storage, private receipt import, and revocation. +- `scripts/lab-ui.js`: reflects the real upload queue count in the Online dock. +- `lab.html`: upload review and receipt-log markup. +- `styles/lab.css`: upload and receipt tool styling. + +## 10. Test Checklist + +The client should keep tests around these contracts: + +- recording emits a local clip and does not upload automatically; +- submit is disabled until consent is checked; +- upload sends `video`, `kind`, `consent_version`, `app_version`, optional + `ghostyle_id`, note, and metrics; +- successful upload creates a durable receipt; +- private receipt hash imports a missing receipt; +- revoke calls `DELETE /api/uploads/:id` with `X-Delete-Token`; +- `410` revocation marks a receipt deleted rather than erasing it. From 541e1eb54a0f19fda4298ed7b123114cb4a8599e Mon Sep 17 00:00:00 2001 From: vecna Date: Wed, 22 Jul 2026 14:54:38 +0200 Subject: [PATCH 09/11] add github action --- .github/workflows/ci.yml | 79 +++++++++++++ README.md | 33 +----- package.json | 2 +- scripts-dev/update-coverage-badge.js | 93 ++++++++++++++++ scripts-dev/update-readme.js | 159 --------------------------- tutorials/github-actions.md | 137 +++++++++++++++++++++++ 6 files changed, 312 insertions(+), 191 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts-dev/update-coverage-badge.js delete mode 100755 scripts-dev/update-readme.js create mode 100644 tutorials/github-actions.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7193b72 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + test: + name: Test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run unit tests with coverage + run: npm run test:coverage + + - name: Run end-to-end tests + run: npm run test:e2e + + update-coverage-badge: + name: Update Coverage Badge + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate coverage report + run: npm run test:coverage + + - name: Update README coverage badge + run: npm run update:coverage-badge + + - name: Commit updated coverage badge + run: | + if git diff --quiet -- README.md; then + echo "No coverage badge changes to commit" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "chore(ci): update coverage badge [skip ci]" + git push diff --git a/README.md b/README.md index f1c4e31..3cee061 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Ghostmaxxing -[![Unit Test Coverage](https://img.shields.io/badge/coverage-73.12%25-yellow)](coverage/) +[![Unit Test Coverage](https://img.shields.io/badge/coverage-66.96%25-yellow)](coverage/) +[![CI](https://github.com/vecna/ghostmaxxing/actions/workflows/ci.yml/badge.svg)](https://github.com/vecna/ghostmaxxing/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-JSDoc-blue)](docs/) [![Source](https://img.shields.io/badge/source-GitHub-black)](https://github.com/vecna/ghostati) @@ -316,36 +317,6 @@ Current reference links: ```text small patch - clear test - stable plugin API - documented behavior -``` - -Good contributions include: - -- new Ghostyles with clear metadata and reproducible test notes; -- tighter unit coverage around renamed/refactored functions; -- e2e scenarios for detection, baseline saving, overlay switching, and match-state transitions; -- CDN self-hosting options; -- clearer model-loading failure states; -- accessibility and mobile UI improvements; -- better documentation for `faceapi` vs `mediapipe` Ghostyle engines; -- additions to [`REFERENCES.json`](REFERENCES.json) following [`PROMPT-REFERENCES-UPDATE.txt`](PROMPT-REFERENCES-UPDATE.txt). - -Please keep claims narrow and technical: say which model, browser, lighting, camera, and threshold produced which result. - - - - - - - - - -**Last commit:** `85f9100` – internationalization added, three languages - - - 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/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, + `[![Unit Test Coverage](${badgeUrl})](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, - `[![Unit Test Coverage](${COVERAGE_BADGE_URL})](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/tutorials/github-actions.md b/tutorials/github-actions.md new file mode 100644 index 0000000..5ca82fc --- /dev/null +++ b/tutorials/github-actions.md @@ -0,0 +1,137 @@ +# GitHub Actions Setup + +This repository can use GitHub Actions to do two separate jobs: + +1. Run unit tests and Playwright end-to-end tests on every pull request to `main` and every push to `main`. +2. Refresh the README coverage badge after successful pushes to `main`. + +The workflow file is: + +```text +.github/workflows/ci.yml +``` + +## What the workflow does + +The workflow is named `CI` and has two jobs. + +### 1. `Test` + +This job runs on: + +- every pull request targeting `main` +- every push to `main` + +It does the following: + +1. checks out the repository +2. installs Node.js 20 +3. runs `npm ci` +4. installs Playwright Chromium and its Linux dependencies +5. runs `npm run test:coverage` +6. runs `npm run test:e2e` + +If this job fails, GitHub shows the failure directly in the pull request checks. + +### 2. `Update Coverage Badge` + +This job runs only when: + +- the event is a push to `main` +- the `Test` job already passed + +It does the following: + +1. runs unit coverage again to produce `coverage/coverage-final.json` +2. runs `npm run update:coverage-badge` +3. commits the updated README badge if the badge changed + +The commit message contains `[skip ci]` so the bot commit does not trigger the workflow again. + +## Files involved + +### Workflow + +```text +.github/workflows/ci.yml +``` + +### Coverage badge updater + +```text +scripts-dev/update-coverage-badge.js +``` + +This script updates only the coverage badge block in `README.md`. + +The older all-in-one README updater is no longer part of the repository workflow, +because commit/changelog rewriting created noisy automation output and did not +help branch protection or test visibility. + +## What you must do in GitHub + +The workflow file alone is not enough to block merges. You must also enable branch protection for `main`. + +### A. Push the workflow to GitHub + +Commit and push these files: + +```text +.github/workflows/ci.yml +scripts-dev/update-coverage-badge.js +tutorials/github-actions.md +``` + +You will usually also push the README change that adds the CI badge: + +```text +README.md +``` + +### B. Enable Actions permissions + +In the GitHub repository: + +1. Open `Settings`. +2. Open `Actions` then `General`. +3. Make sure Actions are allowed for the repository. +4. Under workflow permissions, allow: + `Read and write permissions` + +That write permission is required because the badge-update job commits the refreshed README back to `main`. + +### C. Protect `main` + +In the GitHub repository: + +1. Open `Settings`. +2. Open `Branches`. +3. Add a branch protection rule for `main`. +4. Enable `Require a pull request before merging`. +5. Enable `Require status checks to pass before merging`. +6. Select the CI test check once it appears after the first workflow run. + +Depending on GitHub's UI, the check name will normally appear as the workflow/job combination, typically similar to: + +```text +CI / Test +``` + +After that, GitHub will not allow merging a pull request into `main` until the tests pass. + +## Local equivalents + +These are the same commands the workflow uses: + +```bash +npm ci +npm run test:coverage +npm run test:e2e +npm run update:coverage-badge +``` + +## Notes + +- The workflow updates the coverage badge only after code is already on `main`. +- The merge gate is provided by the pull request test run plus branch protection. +- If you want the badge update to happen in a separate maintenance branch instead of committing directly to `main`, the workflow should be changed to open a pull request instead of pushing. \ No newline at end of file From e56e15c98bb1f1cb2acfa5b332303d6989dea754 Mon Sep 17 00:00:00 2001 From: vecna Date: Wed, 22 Jul 2026 15:09:02 +0200 Subject: [PATCH 10/11] fixed docs --- scripts/transfer.js | 74 ++++++++++++++++++++++++++++++++----- tutorials/github-actions.md | 33 +---------------- tutorials/tutorials.json | 3 ++ 3 files changed, 69 insertions(+), 41 deletions(-) diff --git a/scripts/transfer.js b/scripts/transfer.js index 04de28f..843aa40 100644 --- a/scripts/transfer.js +++ b/scripts/transfer.js @@ -1,11 +1,44 @@ /** * @module transfer * @description - * Browser-only Ghostyle transfer lab. It extracts the visual paint delta from - * an optional before image plus a painted after image, normalizes that paint - * into a canonical face frame, and composites it onto a target face. When - * face-api landmarks are available, it upgrades from box placement to - * triangle-based mesh warping. + * # 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'; @@ -14,9 +47,32 @@ const RESULT_MAX = 900; const MODEL_ROOT = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/'; /** - * @typedef {{x:number, y:number}} Point - * @typedef {{x:number, y:number, w:number, h:number}} Box - * @typedef {{img:HTMLImageElement|null, box:Box|null, scale:number, dispW:number, dispH:number}} TransferSlot + * 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>} */ @@ -448,7 +504,7 @@ function extractMatte(threshold, feather) { * Approximate the dominant skin/reference color by sparse sampling. * * @param {ImageData} imageData - Canonical source image data. - * @returns {[number, number, number]} Average RGB tuple. + * @returns {RgbTuple} Average RGB tuple. */ function medianColor(imageData) { let r = 0; diff --git a/tutorials/github-actions.md b/tutorials/github-actions.md index 5ca82fc..1926cfc 100644 --- a/tutorials/github-actions.md +++ b/tutorials/github-actions.md @@ -88,38 +88,7 @@ You will usually also push the README change that adds the CI badge: README.md ``` -### B. Enable Actions permissions - -In the GitHub repository: - -1. Open `Settings`. -2. Open `Actions` then `General`. -3. Make sure Actions are allowed for the repository. -4. Under workflow permissions, allow: - `Read and write permissions` - -That write permission is required because the badge-update job commits the refreshed README back to `main`. - -### C. Protect `main` - -In the GitHub repository: - -1. Open `Settings`. -2. Open `Branches`. -3. Add a branch protection rule for `main`. -4. Enable `Require a pull request before merging`. -5. Enable `Require status checks to pass before merging`. -6. Select the CI test check once it appears after the first workflow run. - -Depending on GitHub's UI, the check name will normally appear as the workflow/job combination, typically similar to: - -```text -CI / Test -``` - -After that, GitHub will not allow merging a pull request into `main` until the tests pass. - -## Local equivalents +### B. Local equivalents These are the same commands the workflow uses: diff --git a/tutorials/tutorials.json b/tutorials/tutorials.json index 9c653d7..202a95a 100644 --- a/tutorials/tutorials.json +++ b/tutorials/tutorials.json @@ -2,6 +2,9 @@ "ghostyle-authoring": { "title": "Ghostyle Authoring Guide" }, + "consent-and-ownership": { + "title": "Consent And Ownership" + }, "brand-voice": { "title": "(internal) Voice/Tone" }, From 0b14fe59e2348d32433324d72eec89b62056c9a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:11:07 +0000 Subject: [PATCH 11/11] fix(e2e): stub CDN deps and replace hardware-dependent waits in overlay-mode test --- package-lock.json | 13 +++++--- tests/e2e/overlay-mode.spec.js | 57 ++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 7 deletions(-) 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/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 + //