feat(video-studio): Add DisguiseVideoWithFaceSelector component and analytics tracking for face-based video processing

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-19 22:55:39 -07:00
parent 077b2e9625
commit 7e5ffe2605
9 changed files with 884 additions and 1 deletions

View file

@ -0,0 +1,3 @@
// Entry point wrapper — delegates to main.ts
// Usage: bun run scripts/generate-dev-data.ts --all
import './main'

View file

@ -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<void> {
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`)
}

View file

@ -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<void> {
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
}
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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`)
}

View file

@ -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<void> {
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<Array<{ code: string }>>(`${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')
}
}

View file

@ -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<void> {
log('\n═══ Attribute Sync: Pull (DB → Filesystem) ═══')
try {
const definitions = await httpGet<Array<{
code: string
name: string
entityType: string
dataType: string
isSearchable: boolean
isMultiple?: boolean
grouping: string
displayOrder: number
description?: string
enumValues?: Array<{ value: string; displayValue: string }>
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<string, typeof definitions>()
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
}
}

View file

@ -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<void> {
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`)
}

View file

@ -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> = {}): 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);
});
});

View file

@ -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<