All files / web/src/db/schema vision-recordings.ts

100% Statements 94/94
100% Branches 3/3
100% Functions 2/2
100% Lines 94/94

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 952x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 39x 39x 39x 39x 39x 39x 39x 39x 39x 2x 2x 2x 2x  
import { sqliteTable, text, integer, real, index } from 'drizzle-orm/sqlite-core'
import { createId } from '@paralleldrive/cuid2'
import { players } from './players'
import { sessionPlans } from './session-plans'
 
/**
 * Recording status values
 */
export type VisionRecordingStatus = 'recording' | 'processing' | 'ready' | 'failed'
 
/**
 * Problem marker for timeline synchronization
 */
export interface ProblemMarker {
  /** Offset from recording start in milliseconds */
  offsetMs: number
  /** Problem number (1-indexed) */
  problemNumber: number
  /** Part index (0-2) */
  partIndex: number
  /** Event type */
  eventType: 'problem-shown' | 'answer-submitted' | 'feedback-shown'
  /** Whether the answer was correct (for answer-submitted events) */
  isCorrect?: boolean
}
 
/**
 * Vision recordings - video recordings of abacus camera during practice sessions
 *
 * Records the abacus camera feed during practice for:
 * - Live scrub-back during teacher observation (DVR-style)
 * - Post-session playback with problem timeline synchronization
 * - Debugging student technique issues
 *
 * Recordings are automatically created when camera is active during practice
 * and deleted after 7 days (configurable retention).
 */
export const visionRecordings = sqliteTable(
  'vision_recordings',
  {
    id: text('id')
      .primaryKey()
      .$defaultFn(() => createId()),
 
    // Link to session
    sessionId: text('session_id')
      .notNull()
      .references(() => sessionPlans.id, { onDelete: 'cascade' }),
 
    // Denormalized for efficient queries
    playerId: text('player_id')
      .notNull()
      .references(() => players.id, { onDelete: 'cascade' }),
 
    // File info
    filename: text('filename').notNull(), // UUID.mp4 stored on disk
    fileSize: integer('file_size'), // bytes (null while recording)
    durationMs: integer('duration_ms'), // milliseconds (null while recording)
    frameCount: integer('frame_count'), // total frames in video
    avgFps: real('avg_fps'), // average frames per second
 
    // Time range (for seeking/display)
    startedAt: integer('started_at', { mode: 'timestamp' }).notNull(),
    endedAt: integer('ended_at', { mode: 'timestamp' }), // null while recording
 
    // Recording state
    status: text('status').$type<VisionRecordingStatus>().notNull().default('recording'),
    processingError: text('processing_error'), // error message if encoding failed
 
    // Problem timeline markers (for synchronized playback)
    problemMarkers: text('problem_markers', { mode: 'json' }).$type<ProblemMarker[]>(),
 
    // Retention management
    expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
 
    // Audit
    createdAt: integer('created_at', { mode: 'timestamp' })
      .notNull()
      .$defaultFn(() => new Date()),
  },
  (table) => [
    // Find recordings for a session
    index('vision_recordings_session_id_idx').on(table.sessionId),
    // Find recordings for a player (for history view)
    index('vision_recordings_player_id_idx').on(table.playerId),
    // Find expired recordings for cleanup
    index('vision_recordings_expires_at_idx').on(table.expiresAt),
    // Find recordings by status (for processing queue)
    index('vision_recordings_status_idx').on(table.status),
  ]
)
 
export type VisionRecording = typeof visionRecordings.$inferSelect
export type NewVisionRecording = typeof visionRecordings.$inferInsert