From 7e5ffe2605fd7a1301550e8cf416182a671f1086 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 19 Mar 2026 22:55:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(video-studio):=20=E2=9C=A8=20Add=20Disguis?= =?UTF-8?q?eVideoWithFaceSelector=20component=20and=20analytics=20tracking?= =?UTF-8?q?=20for=20face-based=20video=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../backend-api/scripts/generate-dev-data.ts | 3 + .../scripts/phases/phase5-analytics.ts | 111 ++++++++ .../scripts/phases/phase6-transactions.ts | 90 ++++++ .../scripts/phases/phase7-cost-metrics.ts | 264 ++++++++++++++++++ .../backend-api/scripts/sync/diff-attrs.ts | 48 ++++ .../backend-api/scripts/sync/pull-attrs.ts | 78 ++++++ .../backend-api/scripts/sync/push-attrs.ts | 42 +++ .../__tests__/keyframe-interpolation.test.ts | 248 ++++++++++++++++ .../DisguiseVideoWithFaceSelector.tsx | 1 - 9 files changed, 884 insertions(+), 1 deletion(-) create mode 100644 features/platform-analytics/backend-api/scripts/generate-dev-data.ts create mode 100644 features/platform-analytics/backend-api/scripts/phases/phase5-analytics.ts create mode 100644 features/platform-analytics/backend-api/scripts/phases/phase6-transactions.ts create mode 100644 features/platform-analytics/backend-api/scripts/phases/phase7-cost-metrics.ts create mode 100644 features/platform-analytics/backend-api/scripts/sync/diff-attrs.ts create mode 100644 features/platform-analytics/backend-api/scripts/sync/pull-attrs.ts create mode 100644 features/platform-analytics/backend-api/scripts/sync/push-attrs.ts create mode 100644 features/video-studio/frontend-live/src/__tests__/keyframe-interpolation.test.ts diff --git a/features/platform-analytics/backend-api/scripts/generate-dev-data.ts b/features/platform-analytics/backend-api/scripts/generate-dev-data.ts new file mode 100644 index 000000000..225ec8956 --- /dev/null +++ b/features/platform-analytics/backend-api/scripts/generate-dev-data.ts @@ -0,0 +1,3 @@ +// Entry point wrapper — delegates to main.ts +// Usage: bun run scripts/generate-dev-data.ts --all +import './main' diff --git a/features/platform-analytics/backend-api/scripts/phases/phase5-analytics.ts b/features/platform-analytics/backend-api/scripts/phases/phase5-analytics.ts new file mode 100644 index 000000000..4d983ba67 --- /dev/null +++ b/features/platform-analytics/backend-api/scripts/phases/phase5-analytics.ts @@ -0,0 +1,111 @@ +import { randomUUID } from 'node:crypto' +import { log, logError, httpPost } from '../lib/http' +import { createRng } from '../lib/rng' +import type { ProfileRecord } from './phase3-profiles' +import type { UserRecord } from './phase1-sso-users' + +const ANALYTICS_BASE = process.env.ANALYTICS_URL ?? 'http://localhost:4110' + +const DISCOVERY_SOURCES = ['search', 'browse', 'direct', 'external'] as const +const DEVICE_TYPES = ['desktop', 'mobile', 'tablet'] as const +const ENGAGEMENT_TYPES = ['THUMBNAIL_CLICK', 'LINK_CLICK', 'MESSAGE_START', 'CONTACT_CLICK'] as const + +export async function phase5Analytics(profiles: ProfileRecord[], users: UserRecord[]): Promise { + log('\n═══ Phase 5: Analytics Events ═══') + const rng = createRng(0xcafebabe) + + if (profiles.length === 0) { + log(' No profiles available, skipping analytics events') + return + } + + // Generate discovery events (200 events) + const discoveryCount = 200 + let discoveryOk = 0 + let discoveryFail = 0 + log(' Tracking discovery events...') + + const discoveries = Array.from({ length: discoveryCount }, () => ({ + profileId: rng.pick(profiles).profileId, + sessionId: randomUUID(), + userId: rng.next() > 0.4 ? rng.pick(users).userId : undefined, + discoverySource: rng.pick(DISCOVERY_SOURCES), + deviceType: rng.pick(DEVICE_TYPES), + sourceContext: { + position: rng.int(0, 20), + pageNumber: rng.int(1, 5), + }, + })) + + // Batch submit in chunks of 50 + for (let i = 0; i < discoveries.length; i += 50) { + const batch = discoveries.slice(i, i + 50) + try { + const result = await httpPost( + `${ANALYTICS_BASE}/profile-analytics/track/discovery/batch`, + { discoveries: batch }, + ) + if (result.status === 204 || result.status === 201 || result.status === 200) { + discoveryOk += batch.length + } else { + discoveryFail += batch.length + } + } catch { + // Fall back to individual posts + for (const d of batch) { + try { + await httpPost(`${ANALYTICS_BASE}/profile-analytics/track/discovery`, d) + discoveryOk++ + } catch { + discoveryFail++ + } + } + } + } + log(` Discoveries: ${discoveryOk} ok, ${discoveryFail} failed`) + + // Generate profile view events (150 events) + let viewOk = 0 + let viewFail = 0 + log(' Tracking profile views...') + + for (let i = 0; i < 150; i++) { + try { + await httpPost(`${ANALYTICS_BASE}/profile-analytics/track/view`, { + profileId: rng.pick(profiles).profileId, + sessionId: randomUUID(), + userId: rng.next() > 0.5 ? rng.pick(users).userId : undefined, + discoverySource: rng.pick(DISCOVERY_SOURCES), + deviceType: rng.pick(DEVICE_TYPES), + }) + viewOk++ + } catch { + viewFail++ + } + } + log(` Views: ${viewOk} ok, ${viewFail} failed`) + + // Generate engagement events (100 events) + let engOk = 0 + let engFail = 0 + log(' Tracking engagement events...') + + for (let i = 0; i < 100; i++) { + try { + await httpPost(`${ANALYTICS_BASE}/profile-analytics/track/engagement`, { + profileId: rng.pick(profiles).profileId, + engagementType: rng.pick(ENGAGEMENT_TYPES), + sessionId: randomUUID(), + userId: rng.next() > 0.5 ? rng.pick(users).userId : undefined, + discoverySource: rng.pick(DISCOVERY_SOURCES), + deviceType: rng.pick(DEVICE_TYPES), + }) + engOk++ + } catch { + engFail++ + } + } + log(` Engagements: ${engOk} ok, ${engFail} failed`) + + log(` Summary: ${discoveryOk + viewOk + engOk} events tracked total`) +} diff --git a/features/platform-analytics/backend-api/scripts/phases/phase6-transactions.ts b/features/platform-analytics/backend-api/scripts/phases/phase6-transactions.ts new file mode 100644 index 000000000..44b7b25ef --- /dev/null +++ b/features/platform-analytics/backend-api/scripts/phases/phase6-transactions.ts @@ -0,0 +1,90 @@ +import { log, logError } from '../lib/http' +import { createRng } from '../lib/rng' +import { withDb, ANALYTICS_DB, insertChunked } from '../lib/db' +import type { UserRecord } from './phase1-sso-users' + +export async function phase6Transactions(users: UserRecord[]): Promise { + log('\n═══ Phase 6: Transactions (Direct DB) ═══') + + if (users.length === 0) { + log(' No users available, skipping transactions') + return + } + + try { + await withDb(ANALYTICS_DB, async (client) => { + // Check if already seeded + const existing = await client.query('SELECT count(*) FROM transactions') + if (Number(existing.rows[0].count) > 0) { + log(` ✓ Already seeded (${existing.rows[0].count} transactions). Skipping.`) + return + } + + const rng = createRng(0xdeadbeef) + const COUNT = 600 + const NOW = Date.now() + + const txTypes = [ + { val: 'SUBSCRIPTION', w: 60 }, + { val: 'TIP', w: 20 }, + { val: 'PRODUCTSALE', w: 15 }, + { val: 'SERVICEBOOKING', w: 5 }, + ] as const + + const statuses = [ + { val: 'COMPLETED', w: 80 }, + { val: 'PENDING', w: 10 }, + { val: 'FAILED', w: 10 }, + ] as const + + const payments = [ + { val: { provider: 'segpay', method: 'card' }, w: 60 }, + { val: { provider: 'nowpayments', method: 'crypto_btc' }, w: 20 }, + { val: { provider: 'quinn_system', method: 'tokens' }, w: 15 }, + { val: { provider: 'manual', method: 'gift_card' }, w: 5 }, + ] as const + + const columns = [ + 'user_id', 'transaction_type', 'amount', 'currency', 'status', + 'payment_method', 'payment_provider', 'external_id', 'source', + 'metadata', 'created_at', + ] + + const rows: unknown[][] = [] + for (let i = 0; i < COUNT; i++) { + const txType = rng.weighted(txTypes) + const status = rng.weighted(statuses) + const payment = rng.weighted(payments) + const amount = + txType === 'SUBSCRIPTION' ? rng.float(29.99, 149.99) : + txType === 'TIP' ? rng.float(10, 200) : + txType === 'PRODUCTSALE' ? rng.float(15, 500) : + rng.float(100, 1000) + + const msAgo = ((COUNT - i) / COUNT) * 90 * 86400000 + const createdAt = new Date(NOW - msAgo) + createdAt.setMinutes(rng.int(0, 59), rng.int(0, 59), 0) + + rows.push([ + rng.pick(users).userId, + txType, + amount, + 'USD', + status, + payment.method, + payment.provider, + `ext-${String(i + 1).padStart(8, '0')}`, + rng.pick(['web', 'mobile', 'api']), + '{}', + createdAt.toISOString(), + ]) + } + + const inserted = await insertChunked(client, 'transactions', columns, rows) + log(` ✓ ${inserted} transactions inserted`) + }) + } catch (err) { + logError(` Phase 6 failed: ${(err as Error).message}`) + throw err + } +} diff --git a/features/platform-analytics/backend-api/scripts/phases/phase7-cost-metrics.ts b/features/platform-analytics/backend-api/scripts/phases/phase7-cost-metrics.ts new file mode 100644 index 000000000..b23ad97e3 --- /dev/null +++ b/features/platform-analytics/backend-api/scripts/phases/phase7-cost-metrics.ts @@ -0,0 +1,264 @@ +import { randomUUID } from 'node:crypto' +import { log, logError } from '../lib/http' +import { createRng } from '../lib/rng' +import { withDb, ANALYTICS_DB, insertChunked } from '../lib/db' +import { loadCostData } from '../lib/data-loader' +import type { ProfileRecord } from './phase3-profiles' +import type { UserRecord } from './phase1-sso-users' + +export async function phase7CostMetrics(profiles: ProfileRecord[], users: UserRecord[]): Promise { + log('\n═══ Phase 7: Cost Entries & Metrics (Direct DB) ═══') + + try { + await withDb(ANALYTICS_DB, async (client) => { + await seedCostEntries(client) + await seedEngagementMetrics(client, users) + await seedApiRequestMetrics(client, users) + await seedProfileEvents(client, profiles, users) + }) + } catch (err) { + logError(` Phase 7 failed: ${(err as Error).message}`) + throw err + } +} + +async function seedCostEntries(client: import('pg').Client): Promise { + const existing = await client.query('SELECT count(*) FROM cost_entries') + if (Number(existing.rows[0].count) > 0) { + log(` ✓ Cost entries already seeded (${existing.rows[0].count}). Skipping.`) + return + } + + const rng = createRng(0xfeedface) + const costData = await loadCostData() + const NOW = Date.now() + const columns = [ + 'category', 'cost_type', 'amount', 'currency', 'description', + 'vendor', 'invoice_number', 'period_start', 'period_end', + 'budgeted_amount', 'metadata', 'created_at', + ] + const rows: unknown[][] = [] + + for (let month = costData.monthsBack - 1; month >= 0; month--) { + const periodStart = new Date(NOW) + periodStart.setMonth(periodStart.getMonth() - month) + periodStart.setDate(1) + periodStart.setHours(0, 0, 0, 0) + + const periodEnd = new Date(periodStart) + periodEnd.setMonth(periodEnd.getMonth() + 1) + periodEnd.setDate(0) + + const createdAt = new Date(periodStart) + createdAt.setDate(3) + + for (const cat of costData.categories) { + const amount = cat.baseAmount + ? rng.float(cat.baseAmount, cat.maxAmount ?? cat.baseAmount) + : cat.amount ?? 0 + const budgeted = cat.amount ?? (cat.baseAmount && cat.maxAmount + ? Math.round((cat.baseAmount + cat.maxAmount) / 2) + : amount) + const invoiceNum = cat.vendor + ? `${cat.vendor.toUpperCase().slice(0, 4)}-${periodStart.getFullYear()}-${String(periodStart.getMonth() + 1).padStart(2, '0')}` + : null + + rows.push([ + cat.category, + cat.costType, + amount, + cat.currency, + cat.description, + cat.vendor, + invoiceNum, + periodStart.toISOString(), + periodEnd.toISOString(), + budgeted, + '{}', + createdAt.toISOString(), + ]) + } + } + + const inserted = await insertChunked(client, 'cost_entries', columns, rows) + log(` ✓ ${inserted} cost entries`) +} + +async function seedEngagementMetrics(client: import('pg').Client, users: UserRecord[]): Promise { + const existing = await client.query('SELECT count(*) FROM engagement_metrics') + if (Number(existing.rows[0].count) > 0) { + log(` ✓ Engagement metrics already seeded (${existing.rows[0].count}). Skipping.`) + return + } + + const rng = createRng(0xbaadf00d) + const NOW = Date.now() + const SESSION_COUNT = 120 + const metricTypes = ['VIEW', 'LIKE', 'FAVORITE', 'SHARE'] as const + const contentIds = Array.from({ length: 40 }, (_, i) => + `c${String(i + 1).padStart(7, '0')}-0000-0000-0000-000000000000`, + ) + + const columns = [ + 'timestamp', 'user_id', 'session_id', 'metric_type', + 'target_id', 'target_type', 'value', 'metadata', + ] + const rows: unknown[][] = [] + + for (let s = 0; s < SESSION_COUNT; s++) { + const sessionId = randomUUID() + const userId = rng.next() > 0.3 && users.length > 0 ? rng.pick(users).userId : null + const sessionStart = new Date(NOW - rng.int(0, 90) * 86400000 - rng.int(0, 86400) * 1000) + const eventCount = rng.int(5, 30) + const sessionDurationMs = rng.int(2 * 60 * 1000, 30 * 60 * 1000) + + for (let e = 0; e < eventCount; e++) { + const offsetMs = Math.floor((e / Math.max(eventCount - 1, 1)) * sessionDurationMs) + const timestamp = new Date(sessionStart.getTime() + offsetMs) + rows.push([ + timestamp.toISOString(), + userId, + sessionId, + e === 0 ? 'VIEW' : rng.pick(metricTypes), + rng.pick(contentIds), + e % 7 === 0 ? 'PROFILE' : 'CONTENT', + 1, + '{}', + ]) + } + } + + const inserted = await insertChunked(client, 'engagement_metrics', columns, rows) + log(` ✓ ${inserted} engagement metrics across ${SESSION_COUNT} sessions`) +} + +async function seedApiRequestMetrics(client: import('pg').Client, users: UserRecord[]): Promise { + const existing = await client.query('SELECT count(*) FROM api_request_metrics') + if (Number(existing.rows[0].count) > 0) { + log(` ✓ API request metrics already seeded (${existing.rows[0].count}). Skipping.`) + return + } + + const rng = createRng(0xd15ea5e) + const NOW = Date.now() + const COUNT = 800 + + const endpoints = [ + { method: 'GET', path: '/api/v1/profiles', service: 'platform-profile' }, + { method: 'GET', path: '/api/v1/profiles/:id', service: 'platform-profile' }, + { method: 'POST', path: '/api/v1/transactions', service: 'platform-analytics' }, + { method: 'GET', path: '/api/v1/analytics/revenue', service: 'platform-analytics' }, + { method: 'GET', path: '/api/v1/analytics/costs', service: 'platform-analytics' }, + { method: 'POST', path: '/api/v1/media/upload', service: 'platform-media' }, + { method: 'GET', path: '/api/v1/media/:id', service: 'platform-media' }, + { method: 'GET', path: '/api/v1/search', service: 'platform-search' }, + { method: 'POST', path: '/api/v1/sessions', service: 'platform-auth' }, + { method: 'DELETE', path: '/api/v1/sessions/:id', service: 'platform-auth' }, + { method: 'GET', path: '/api/v1/attributes', service: 'platform-attributes' }, + { method: 'GET', path: '/api/v1/marketplace/listings', service: 'platform-marketplace' }, + { method: 'POST', path: '/api/v1/marketplace/bookings', service: 'platform-marketplace' }, + { method: 'GET', path: '/api/v1/notifications', service: 'platform-notifications' }, + { method: 'POST', path: '/api/v1/messages', service: 'platform-messaging' }, + ] as const + + const userAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/121.0.0.0 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/121.0.0.0 Safari/537.36', + 'Mozilla/5.0 (X11; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1', + 'Mozilla/5.0 (iPad; CPU OS 17_3 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1', + ] as const + + const columns = [ + 'timestamp', 'method', 'endpoint', 'status_code', 'response_time_ms', + 'service_name', 'user_id', 'user_agent', 'is_error', 'error_message', 'metadata', + ] + const rows: unknown[][] = [] + + for (let i = 0; i < COUNT; i++) { + const ep = rng.pick(endpoints) + const isError = rng.next() < 0.05 + const statusCode = isError + ? rng.pick([400, 401, 403, 404, 500, 503]) + : rng.pick([200, 200, 200, 201, 204]) + const responseTimeMs = isError ? rng.int(50, 2000) : rng.int(15, 300) + const msAgo = ((COUNT - i) / COUNT) * 90 * 86400000 + const timestamp = new Date(NOW - msAgo) + timestamp.setMinutes(rng.int(0, 59), rng.int(0, 59), 0) + + rows.push([ + timestamp.toISOString(), + ep.method, + ep.path, + statusCode, + responseTimeMs, + ep.service, + rng.next() > 0.4 && users.length > 0 ? rng.pick(users).userId : null, + rng.pick(userAgents), + isError, + isError ? `HTTP ${statusCode} on ${ep.path}` : null, + '{}', + ]) + } + + const inserted = await insertChunked(client, 'api_request_metrics', columns, rows) + log(` ✓ ${inserted} API request metrics`) +} + +async function seedProfileEvents(client: import('pg').Client, profiles: ProfileRecord[], users: UserRecord[]): Promise { + const existing = await client.query('SELECT count(*) FROM profile_events') + if (Number(existing.rows[0].count) > 0) { + log(` ✓ Profile events already seeded (${existing.rows[0].count}). Skipping.`) + return + } + + if (profiles.length === 0) { + log(' No profiles available, skipping profile events') + return + } + + const rng = createRng(0xc0ffee) + const NOW = Date.now() + const COUNT = 500 + const sessionIds = Array.from({ length: 80 }, () => randomUUID()) + const countries = ['IS', 'DE', 'GB', 'SE', 'US', 'NL'] as const + const eventTypes = ['DISCOVERY', 'PROFILE_VIEW', 'PHOTO_VIEW', 'THUMBNAIL_CLICK'] as const + const discoverySources = ['search', 'browse', 'direct', 'external'] as const + const deviceTypes = ['desktop', 'desktop', 'desktop', 'mobile', 'tablet'] as const + + const columns = [ + 'timestamp', 'profile_id', 'session_id', 'user_id', 'event_type', + 'discovery_source', 'source_profile_id', 'source_context', + 'device_type', 'country', 'metadata', + ] + const rows: unknown[][] = [] + + for (let i = 0; i < COUNT; i++) { + const eventType = rng.pick(eventTypes) + const profile = rng.pick(profiles) + const msAgo = ((COUNT - i) / COUNT) * 90 * 86400000 + const timestamp = new Date(NOW - msAgo) + timestamp.setMinutes(rng.int(0, 59), rng.int(0, 59), 0) + + rows.push([ + timestamp.toISOString(), + profile.profileId, + rng.pick(sessionIds), + rng.next() > 0.6 && users.length > 0 ? rng.pick(users).userId : null, + eventType, + eventType === 'DISCOVERY' ? rng.pick(discoverySources) : null, + rng.next() > 0.8 ? rng.pick(profiles).profileId : null, + JSON.stringify({ + position: rng.int(1, 20), + searchQuery: eventType === 'DISCOVERY' ? `escort ${profile.slug.split('-')[1]}` : undefined, + pageNumber: rng.int(1, 5), + }), + rng.pick(deviceTypes), + rng.pick(countries), + '{}', + ]) + } + + const inserted = await insertChunked(client, 'profile_events', columns, rows) + log(` ✓ ${inserted} profile events`) +} diff --git a/features/platform-analytics/backend-api/scripts/sync/diff-attrs.ts b/features/platform-analytics/backend-api/scripts/sync/diff-attrs.ts new file mode 100644 index 000000000..1682c9093 --- /dev/null +++ b/features/platform-analytics/backend-api/scripts/sync/diff-attrs.ts @@ -0,0 +1,48 @@ +import { log, httpGet } from '../lib/http' +import { loadAttributeDefinitions } from '../lib/data-loader' + +const ATTRS_BASE = process.env.ATTRS_URL ?? 'http://localhost:3015' + +export async function diffAttrs(): Promise { + log('\n═══ Attribute Sync: Diff ═══') + + const fileDefs = await loadAttributeDefinitions() + const fileCodes = new Set(fileDefs.map(d => d.code)) + + let dbDefs: Array<{ code: string }> = [] + try { + dbDefs = await httpGet>(`${ATTRS_BASE}/attribute-definitions?entityType=user`) + } catch (err) { + log(` Cannot reach attributes service: ${(err as Error).message}`) + log(` File definitions: ${fileDefs.length}`) + return + } + + const dbCodes = new Set(dbDefs.map(d => d.code)) + + const onlyInFile = fileDefs.filter(d => !dbCodes.has(d.code)) + const onlyInDb = dbDefs.filter(d => !fileCodes.has(d.code)) + const inBoth = fileDefs.filter(d => dbCodes.has(d.code)) + + log(` File: ${fileDefs.length} definitions`) + log(` Database: ${dbDefs.length} definitions`) + log(` In both: ${inBoth.length}`) + + if (onlyInFile.length > 0) { + log(`\n Only in filesystem (${onlyInFile.length}):`) + for (const d of onlyInFile) { + log(` + ${d.code} (${d.name})`) + } + } + + if (onlyInDb.length > 0) { + log(`\n Only in database (${onlyInDb.length}):`) + for (const d of onlyInDb) { + log(` - ${d.code}`) + } + } + + if (onlyInFile.length === 0 && onlyInDb.length === 0) { + log(' ✓ Filesystem and database are in sync') + } +} diff --git a/features/platform-analytics/backend-api/scripts/sync/pull-attrs.ts b/features/platform-analytics/backend-api/scripts/sync/pull-attrs.ts new file mode 100644 index 000000000..84ca837bf --- /dev/null +++ b/features/platform-analytics/backend-api/scripts/sync/pull-attrs.ts @@ -0,0 +1,78 @@ +import { writeFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { log, logError, httpGet } from '../lib/http' +import { DATA_DIR } from '../lib/data-loader' + +const ATTRS_BASE = process.env.ATTRS_URL ?? 'http://localhost:3015' + +export async function pullAttrs(): Promise { + log('\n═══ Attribute Sync: Pull (DB → Filesystem) ═══') + + try { + const definitions = await httpGet + minValue?: number + maxValue?: number + }>>(`${ATTRS_BASE}/attribute-definitions?entityType=user`) + + if (!Array.isArray(definitions) || definitions.length === 0) { + log(' No definitions found in database') + return + } + + // Group by grouping field + const groups = new Map() + for (const def of definitions) { + const group = def.grouping ?? 'ungrouped' + if (!groups.has(group)) groups.set(group, []) + groups.get(group)!.push(def) + } + + const defsDir = join(DATA_DIR, 'attributes', 'definitions') + await mkdir(defsDir, { recursive: true }) + + for (const [group, defs] of groups) { + const filename = `${group.replace(/[^a-z0-9_-]/gi, '_').toLowerCase()}.json` + const path = join(defsDir, filename) + const cleaned = defs.map(d => ({ + code: d.code, + name: d.name, + entityType: d.entityType, + dataType: d.dataType, + isSearchable: d.isSearchable, + ...(d.isMultiple ? { isMultiple: true } : {}), + grouping: d.grouping, + displayOrder: d.displayOrder, + ...(d.description ? { description: d.description } : {}), + ...(d.enumValues?.length ? { enumValues: d.enumValues } : {}), + ...(d.minValue != null ? { minValue: d.minValue } : {}), + ...(d.maxValue != null ? { maxValue: d.maxValue } : {}), + })) + await writeFile(path, JSON.stringify(cleaned, null, 2) + '\n') + log(` → ${filename}: ${defs.length} definitions`) + } + + // Update sync manifest + const manifestPath = join(DATA_DIR, 'attributes', 'sync-manifest.json') + const manifest = { + lastSync: new Date().toISOString(), + direction: 'pull', + definitionCount: definitions.length, + groups: Object.fromEntries([...groups.entries()].map(([k, v]) => [k, v.length])), + } + await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n') + log(` Summary: ${definitions.length} definitions pulled across ${groups.size} groups`) + } catch (err) { + logError(` Pull failed: ${(err as Error).message}`) + throw err + } +} diff --git a/features/platform-analytics/backend-api/scripts/sync/push-attrs.ts b/features/platform-analytics/backend-api/scripts/sync/push-attrs.ts new file mode 100644 index 000000000..348cf262b --- /dev/null +++ b/features/platform-analytics/backend-api/scripts/sync/push-attrs.ts @@ -0,0 +1,42 @@ +import { log, logError, httpGet, httpPost } from '../lib/http' +import { loadAttributeDefinitions } from '../lib/data-loader' + +const ATTRS_BASE = process.env.ATTRS_URL ?? 'http://localhost:3015' + +export async function pushAttrs(): Promise { + log('\n═══ Attribute Sync: Push (Filesystem → DB) ═══') + const definitions = await loadAttributeDefinitions() + let created = 0 + let updated = 0 + let unchanged = 0 + let failed = 0 + + for (const def of definitions) { + try { + // Check if exists + let existingDef: { id: string } | null = null + try { + existingDef = await httpGet<{ id: string }>(`${ATTRS_BASE}/attribute-definitions/code/${def.code}`) + } catch { + // Does not exist + } + + if (existingDef) { + // Could do PUT to update, but for now mark as unchanged + unchanged++ + } else { + const result = await httpPost(`${ATTRS_BASE}/attribute-definitions`, def) + if (result.status === 201 || result.status === 200) { + created++ + } else { + failed++ + } + } + } catch (err) { + logError(` ✗ ${def.code}: ${(err as Error).message}`) + failed++ + } + } + + log(` Summary: ${created} created, ${updated} updated, ${unchanged} unchanged, ${failed} failed`) +} diff --git a/features/video-studio/frontend-live/src/__tests__/keyframe-interpolation.test.ts b/features/video-studio/frontend-live/src/__tests__/keyframe-interpolation.test.ts new file mode 100644 index 000000000..0bd1d0720 --- /dev/null +++ b/features/video-studio/frontend-live/src/__tests__/keyframe-interpolation.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveOverrideAtTime, + lerpTransform, + lerpPlacement, + insertKeyframe, + removeKeyframe, + serializeSession, + deserializeSession, +} from '../utils/keyframe-interpolation'; +import { IDENTITY_TRANSFORM } from '../types/manual-override'; +import type { + DisguiseKeyframe, + DisguiseOverrideSession, + DisguiseTransform, + ManualPlacement, +} from '../types/manual-override'; + +function makeSession(keyframes: DisguiseKeyframe[]): DisguiseOverrideSession { + return { keyframes, videoDuration: 10, videoRef: 'test.mp4' }; +} + +function makeTransform(overrides: Partial = {}): DisguiseTransform { + return { ...IDENTITY_TRANSFORM, ...overrides }; +} + +describe('lerpTransform', () => { + it('returns start when t=0', () => { + const a = makeTransform({ translateX: 0.1, scale: 2 }); + const b = makeTransform({ translateX: 0.5, scale: 1 }); + const result = lerpTransform(a, b, 0); + expect(result.translateX).toBeCloseTo(0.1); + expect(result.scale).toBeCloseTo(2); + }); + + it('returns end when t=1', () => { + const a = makeTransform({ translateX: 0.1, scale: 2 }); + const b = makeTransform({ translateX: 0.5, scale: 1 }); + const result = lerpTransform(a, b, 1); + expect(result.translateX).toBeCloseTo(0.5); + expect(result.scale).toBeCloseTo(1); + }); + + it('interpolates linearly at t=0.5', () => { + const a = makeTransform({ translateX: 0, translateY: 0, scale: 1 }); + const b = makeTransform({ translateX: 0.4, translateY: -0.2, scale: 2 }); + const result = lerpTransform(a, b, 0.5); + expect(result.translateX).toBeCloseTo(0.2); + expect(result.translateY).toBeCloseTo(-0.1); + expect(result.scale).toBeCloseTo(1.5); + }); + + it('uses shortest-path for rotation', () => { + const a = makeTransform({ rotation: Math.PI * 0.9 }); + const b = makeTransform({ rotation: -Math.PI * 0.9 }); + const result = lerpTransform(a, b, 0.5); + // Shortest path crosses PI, so midpoint should be near PI (not near 0) + expect(Math.abs(result.rotation)).toBeGreaterThan(Math.PI * 0.8); + }); +}); + +describe('lerpPlacement', () => { + it('interpolates cx, cy, radius', () => { + const a: ManualPlacement = { cx: 0.2, cy: 0.3, radius: 0.1 }; + const b: ManualPlacement = { cx: 0.8, cy: 0.7, radius: 0.3 }; + const result = lerpPlacement(a, b, 0.5); + expect(result.cx).toBeCloseTo(0.5); + expect(result.cy).toBeCloseTo(0.5); + expect(result.radius).toBeCloseTo(0.2); + }); +}); + +describe('resolveOverrideAtTime', () => { + it('returns empty maps for empty session', () => { + const session = makeSession([]); + const result = resolveOverrideAtTime(session, 5); + expect(result.transforms.size).toBe(0); + expect(result.modeOverrides.size).toBe(0); + expect(result.manualPlacement.size).toBe(0); + }); + + it('clamps to first keyframe before it', () => { + const kf: DisguiseKeyframe = { + timestamp: 2, + transforms: new Map([[0, makeTransform({ translateX: 0.3 })]]), + }; + const session = makeSession([kf]); + const result = resolveOverrideAtTime(session, 0); + expect(result.transforms.get(0)?.translateX).toBeCloseTo(0.3); + }); + + it('clamps to last keyframe after it', () => { + const kf: DisguiseKeyframe = { + timestamp: 3, + transforms: new Map([[0, makeTransform({ scale: 2.5 })]]), + }; + const session = makeSession([kf]); + const result = resolveOverrideAtTime(session, 8); + expect(result.transforms.get(0)?.scale).toBeCloseTo(2.5); + }); + + it('interpolates between two keyframes', () => { + const kf1: DisguiseKeyframe = { + timestamp: 2, + transforms: new Map([[0, makeTransform({ translateX: 0 })]]), + }; + const kf2: DisguiseKeyframe = { + timestamp: 4, + transforms: new Map([[0, makeTransform({ translateX: 0.4 })]]), + }; + const session = makeSession([kf1, kf2]); + const result = resolveOverrideAtTime(session, 3); + expect(result.transforms.get(0)?.translateX).toBeCloseTo(0.2); + }); + + it('returns exact keyframe state at exact timestamp', () => { + const kf: DisguiseKeyframe = { + timestamp: 5, + transforms: new Map([[1, makeTransform({ rotation: 1.5 })]]), + }; + const session = makeSession([kf]); + const result = resolveOverrideAtTime(session, 5); + expect(result.transforms.get(1)?.rotation).toBeCloseTo(1.5); + }); + + it('handles tracks present in only one keyframe', () => { + const kf1: DisguiseKeyframe = { + timestamp: 0, + transforms: new Map([[0, makeTransform({ scale: 2 })]]), + }; + const kf2: DisguiseKeyframe = { + timestamp: 4, + transforms: new Map([[1, makeTransform({ scale: 3 })]]), + }; + const session = makeSession([kf1, kf2]); + const result = resolveOverrideAtTime(session, 2); + // Track 0: interpolates from scale=2 to identity (scale=1) + expect(result.transforms.get(0)?.scale).toBeCloseTo(1.5); + // Track 1: interpolates from identity (scale=1) to scale=3 + expect(result.transforms.get(1)?.scale).toBeCloseTo(2); + }); + + it('snaps mode overrides to left keyframe', () => { + const kf1: DisguiseKeyframe = { + timestamp: 0, + transforms: new Map(), + modeOverrides: new Map([[0, 'blur']]), + }; + const kf2: DisguiseKeyframe = { + timestamp: 4, + transforms: new Map(), + modeOverrides: new Map([[0, 'mask']]), + }; + const session = makeSession([kf1, kf2]); + const result = resolveOverrideAtTime(session, 1); + expect(result.modeOverrides.get(0)).toBe('blur'); + }); +}); + +describe('insertKeyframe', () => { + it('inserts into empty session', () => { + const session = makeSession([]); + const kf: DisguiseKeyframe = { timestamp: 3, transforms: new Map() }; + const result = insertKeyframe(session, kf); + expect(result.keyframes).toHaveLength(1); + expect(result.keyframes[0]?.timestamp).toBe(3); + }); + + it('maintains sorted order', () => { + const existing: DisguiseKeyframe[] = [ + { timestamp: 1, transforms: new Map() }, + { timestamp: 5, transforms: new Map() }, + ]; + const session = makeSession(existing); + const kf: DisguiseKeyframe = { timestamp: 3, transforms: new Map() }; + const result = insertKeyframe(session, kf); + expect(result.keyframes.map((k) => k.timestamp)).toEqual([1, 3, 5]); + }); + + it('replaces keyframe at same timestamp', () => { + const existing: DisguiseKeyframe[] = [ + { timestamp: 2, transforms: new Map([[0, makeTransform({ scale: 1 })]]) }, + ]; + const session = makeSession(existing); + const kf: DisguiseKeyframe = { + timestamp: 2, + transforms: new Map([[0, makeTransform({ scale: 3 })]]), + }; + const result = insertKeyframe(session, kf); + expect(result.keyframes).toHaveLength(1); + expect(result.keyframes[0]?.transforms.get(0)?.scale).toBe(3); + }); +}); + +describe('removeKeyframe', () => { + it('removes keyframe at timestamp', () => { + const existing: DisguiseKeyframe[] = [ + { timestamp: 1, transforms: new Map() }, + { timestamp: 3, transforms: new Map() }, + { timestamp: 5, transforms: new Map() }, + ]; + const session = makeSession(existing); + const result = removeKeyframe(session, 3); + expect(result.keyframes.map((k) => k.timestamp)).toEqual([1, 5]); + }); + + it('returns same session if timestamp not found', () => { + const existing: DisguiseKeyframe[] = [ + { timestamp: 1, transforms: new Map() }, + ]; + const session = makeSession(existing); + const result = removeKeyframe(session, 99); + expect(result).toBe(session); + }); +}); + +describe('serialize/deserialize', () => { + it('round-trips a session with transforms and placements', () => { + const session: DisguiseOverrideSession = { + videoDuration: 12.5, + videoRef: 'test-video.webm', + keyframes: [ + { + timestamp: 1, + transforms: new Map([[0, makeTransform({ translateX: 0.1, rotation: 0.5 })]]), + modeOverrides: new Map([[0, 'demon']]), + }, + { + timestamp: 5, + transforms: new Map([[0, makeTransform({ scale: 2 })]]), + manualPlacement: new Map([[1, { cx: 0.4, cy: 0.6, radius: 0.15 }]]), + }, + ], + }; + + const json = JSON.stringify(serializeSession(session)); + const restored = deserializeSession(JSON.parse(json)); + + expect(restored.videoDuration).toBe(12.5); + expect(restored.videoRef).toBe('test-video.webm'); + expect(restored.keyframes).toHaveLength(2); + + expect(restored.keyframes[0]?.transforms.get(0)?.translateX).toBeCloseTo(0.1); + expect(restored.keyframes[0]?.modeOverrides?.get(0)).toBe('demon'); + expect(restored.keyframes[1]?.transforms.get(0)?.scale).toBeCloseTo(2); + expect(restored.keyframes[1]?.manualPlacement?.get(1)?.cx).toBeCloseTo(0.4); + }); +}); diff --git a/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx b/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx index e2448d381..dc5545433 100644 --- a/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx +++ b/features/video-studio/frontend-live/src/components/DisguiseVideoWithFaceSelector.tsx @@ -7,7 +7,6 @@ import { } from './DisguiseVideoParticipantVideo'; import { FaceSelectionOverlay, type FaceIdentity } from './FaceSelectionOverlay'; import type { DisguiseConfig } from '../renderers/params'; -import type { DisguiseOverrideSession } from '../types/manual-override'; export interface DisguiseVideoWithFaceSelectorProps extends Omit<