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:
parent
077b2e9625
commit
7e5ffe2605
9 changed files with 884 additions and 1 deletions
|
|
@ -0,0 +1,3 @@
|
|||
// Entry point wrapper — delegates to main.ts
|
||||
// Usage: bun run scripts/generate-dev-data.ts --all
|
||||
import './main'
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue