videoCodeAnalyzer.ts•16.3 kB
import { LoomClient } from './loomClient'
import type { VideoAnalysisService } from './videoAnalysis'
import { execSync } from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
export interface CodeContext {
files: string[]
components: string[]
routes: string[]
apis: string[]
errors: string[]
keywords: string[]
}
export interface VideoCodeAnalysis {
videoId: string
videoTitle: string
codeContext: CodeContext
relevantFiles: FileMatch[]
suggestedFiles: string[]
analysisNotes: string[]
confidenceScore: number
}
export interface FileMatch {
path: string
relevance: number
reason: string
snippets?: CodeSnippet[]
}
export interface CodeSnippet {
line: number
content: string
context: string
}
export class VideoCodeAnalyzer {
constructor(
private loomClient: LoomClient,
private videoAnalysisService: VideoAnalysisService,
private codebasePath: string
) {}
async analyzeVideoWithCodebase(videoUrl: string): Promise<VideoCodeAnalysis> {
// Extract video ID from URL
const videoId = LoomClient.extractVideoId(videoUrl)
if (!videoId) {
throw new Error(`Invalid Loom URL: ${videoUrl}`)
}
// Create LoomVideo object
const loomVideo = {
url: videoUrl,
videoId: videoId,
source: 'direct' as const,
}
// Get video analysis
const videoAnalysis = await this.videoAnalysisService.analyzeVideo(loomVideo, {
includeTranscript: true,
detectAccounts: true,
})
// Extract code context from video
const codeContext = await this.extractCodeContext(videoAnalysis)
// Search codebase for relevant files
const relevantFiles = await this.findRelevantFiles(codeContext)
// Generate suggested files based on patterns
const suggestedFiles = await this.suggestAdditionalFiles(codeContext, relevantFiles)
// Calculate confidence score
const confidenceScore = this.calculateConfidence(relevantFiles, codeContext)
// Generate analysis notes
const analysisNotes = this.generateAnalysisNotes(videoAnalysis, codeContext, relevantFiles)
return {
videoId: videoAnalysis.videoId,
videoTitle: videoAnalysis.metadata?.name || 'Untitled',
codeContext,
relevantFiles,
suggestedFiles,
analysisNotes,
confidenceScore,
}
}
private async extractCodeContext(videoAnalysis: any): Promise<CodeContext> {
const context: CodeContext = {
files: [],
components: [],
routes: [],
apis: [],
errors: [],
keywords: [],
}
// Extract from video title and description
if (videoAnalysis.metadata?.name) {
context.keywords.push(...this.extractKeywords(videoAnalysis.metadata.name))
}
// Extract from transcript if available
if (videoAnalysis.transcript?.segments) {
for (const segment of videoAnalysis.transcript.segments) {
const text = segment.text.toLowerCase()
// Look for file mentions
const fileMatches = text.match(/[\w-]+\.(js|jsx|ts|tsx|css|scss|json|md)/g)
if (fileMatches) {
context.files.push(...fileMatches)
}
// Look for component names (capitalized words)
const componentMatches = text.match(
/\b[A-Z][a-zA-Z]+(?:Component|Page|Modal|Form|Button|List|View)?\b/g
)
if (componentMatches) {
context.components.push(...componentMatches)
}
// Look for routes/URLs
const routeMatches = text.match(/\/[\w-/]+/g)
if (routeMatches) {
context.routes.push(...routeMatches)
}
// Look for API endpoints
const apiMatches = text.match(
/(?:api|endpoint|fetch|post|get|put|delete)\s+[\w-/]+/g
)
if (apiMatches) {
context.apis.push(...apiMatches)
}
// Look for error messages
const errorMatches = text.match(
/(?:error|exception|failed|cannot|unable|invalid)[\s\w]+/g
)
if (errorMatches) {
context.errors.push(...errorMatches)
}
}
}
// Extract from visual elements (UI text, error messages, etc.)
if (videoAnalysis.visualElements) {
context.keywords.push(...(videoAnalysis.visualElements.uiText || []))
context.errors.push(...(videoAnalysis.visualElements.errorMessages || []))
}
// Deduplicate
context.files = [...new Set(context.files)]
context.components = [...new Set(context.components)]
context.routes = [...new Set(context.routes)]
context.apis = [...new Set(context.apis)]
context.errors = [...new Set(context.errors)]
context.keywords = [...new Set(context.keywords)]
return context
}
private async findRelevantFiles(context: CodeContext): Promise<FileMatch[]> {
const matches: FileMatch[] = []
// Search for exact file matches
for (const file of context.files) {
const foundFiles = this.searchFiles(file)
for (const foundFile of foundFiles) {
matches.push({
path: foundFile,
relevance: 1.0,
reason: `Exact file name match: ${file}`,
snippets: await this.extractRelevantSnippets(foundFile, context),
})
}
}
// Search for components
for (const component of context.components) {
const componentFiles = this.searchFiles(`*${component}*`)
for (const file of componentFiles) {
if (!matches.some((m) => m.path === file)) {
matches.push({
path: file,
relevance: 0.8,
reason: `Component name match: ${component}`,
snippets: await this.extractRelevantSnippets(file, context),
})
}
}
}
// Search for routes in router files
if (context.routes.length > 0) {
const routerFiles = this.searchInFiles(context.routes.join('|'))
for (const file of routerFiles) {
if (!matches.some((m) => m.path === file)) {
matches.push({
path: file,
relevance: 0.7,
reason: `Route definition found`,
snippets: await this.extractRelevantSnippets(file, context),
})
}
}
}
// Search for API endpoints
if (context.apis.length > 0) {
const apiFiles = this.searchInFiles(context.apis.join('|'))
for (const file of apiFiles) {
if (!matches.some((m) => m.path === file)) {
matches.push({
path: file,
relevance: 0.7,
reason: `API endpoint found`,
snippets: await this.extractRelevantSnippets(file, context),
})
}
}
}
// Search for error messages
if (context.errors.length > 0) {
const errorKeywords = context.errors
.map((e) => e.split(' ').slice(0, 3).join(' '))
.filter((e) => e.length > 5)
if (errorKeywords.length > 0) {
const errorFiles = this.searchInFiles(errorKeywords.join('|'))
for (const file of errorFiles) {
if (!matches.some((m) => m.path === file)) {
matches.push({
path: file,
relevance: 0.6,
reason: `Error message found`,
snippets: await this.extractRelevantSnippets(file, context),
})
}
}
}
}
// Sort by relevance
return matches.sort((a, b) => b.relevance - a.relevance)
}
private searchFiles(pattern: string): string[] {
try {
const result = execSync(
`find ${this.codebasePath} -type f -name "${pattern}" -not -path "*/node_modules/*" -not -path "*/.git/*" 2>/dev/null | head -20`,
{ encoding: 'utf8' }
)
return result.split('\n').filter((f) => f.length > 0)
} catch {
return []
}
}
private searchInFiles(pattern: string): string[] {
try {
// Use ripgrep if available, otherwise fall back to grep
const command = this.isCommandAvailable('rg')
? `rg -l "${pattern}" ${this.codebasePath} --type-add 'web:*.{js,jsx,ts,tsx}' -t web --max-count 1 | head -20`
: `grep -r -l "${pattern}" ${this.codebasePath} --include="*.js" --include="*.jsx" --include="*.ts" --include="*.tsx" | head -20`
const result = execSync(command, { encoding: 'utf8' })
return result.split('\n').filter((f) => f.length > 0)
} catch {
return []
}
}
private isCommandAvailable(command: string): boolean {
try {
execSync(`which ${command}`, { encoding: 'utf8' })
return true
} catch {
return false
}
}
private async extractRelevantSnippets(
filePath: string,
context: CodeContext
): Promise<CodeSnippet[]> {
const snippets: CodeSnippet[] = []
try {
const content = fs.readFileSync(filePath, 'utf8')
const lines = content.split('\n')
// Search for relevant lines
const searchTerms = [
...context.components,
...context.routes,
...context.apis.map((a) => a.split(' ').pop() || ''),
...context.errors.map((e) => e.split(' ').slice(0, 3).join(' ')),
...context.keywords,
].filter((t) => t && t.length > 2)
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineLower = line.toLowerCase()
for (const term of searchTerms) {
if (lineLower.includes(term.toLowerCase())) {
snippets.push({
line: i + 1,
content: line.trim(),
context: term,
})
break
}
}
}
} catch (error) {
// File read error
}
return snippets.slice(0, 10) // Limit to 10 most relevant snippets
}
private async suggestAdditionalFiles(
context: CodeContext,
foundFiles: FileMatch[]
): Promise<string[]> {
const suggestions: Set<string> = new Set()
// Suggest test files for found components
for (const file of foundFiles) {
if (file.path.match(/\.(jsx?|tsx?)$/)) {
const testFile = file.path.replace(/\.(jsx?|tsx?)$/, '.test.$1')
if (fs.existsSync(testFile)) {
suggestions.add(testFile)
}
}
}
// Suggest style files
for (const component of context.components) {
const stylePatterns = [
`${component}.css`,
`${component}.scss`,
`${component}.module.css`,
`${component}.module.scss`,
]
for (const pattern of stylePatterns) {
const files = this.searchFiles(pattern)
files.forEach((f) => suggestions.add(f))
}
}
// Suggest API route handlers based on routes
for (const route of context.routes) {
const apiFiles = this.searchInFiles(`"${route}"`)
apiFiles.forEach((f) => suggestions.add(f))
}
return Array.from(suggestions).filter((f) => !foundFiles.some((fm) => fm.path === f))
}
private calculateConfidence(files: FileMatch[], context: CodeContext): number {
let score = 0
let factors = 0
// Factor 1: File matches
if (files.length > 0) {
score += Math.min(files.length / 5, 1) * 0.3
factors++
}
// Factor 2: High relevance matches
const highRelevance = files.filter((f) => f.relevance >= 0.8).length
if (highRelevance > 0) {
score += Math.min(highRelevance / 3, 1) * 0.3
factors++
}
// Factor 3: Component matches
if (context.components.length > 0) {
score += Math.min(context.components.length / 5, 1) * 0.2
factors++
}
// Factor 4: Error message matches
if (context.errors.length > 0 && files.some((f) => f.reason.includes('Error'))) {
score += 0.2
factors++
}
return factors > 0 ? score / factors : 0
}
private generateAnalysisNotes(
videoAnalysis: any,
context: CodeContext,
files: FileMatch[]
): string[] {
const notes: string[] = []
// Video summary
if (videoAnalysis.metadata?.name) {
notes.push(
`Video: "${videoAnalysis.metadata.name}" (${this.formatDuration(videoAnalysis.metadata.video_properties?.duration)})`
)
}
// Key findings
if (context.components.length > 0) {
notes.push(`Components identified: ${context.components.slice(0, 5).join(', ')}`)
}
if (context.routes.length > 0) {
notes.push(`Routes/pages shown: ${context.routes.slice(0, 5).join(', ')}`)
}
if (context.errors.length > 0) {
notes.push(`Errors/issues: ${context.errors.slice(0, 3).join('; ')}`)
}
// File recommendations
if (files.length > 0) {
notes.push(
`Most relevant files: ${files
.slice(0, 3)
.map((f) => path.basename(f.path))
.join(', ')}`
)
}
// Confidence assessment
const confidence = this.calculateConfidence(files, context)
if (confidence > 0.7) {
notes.push('High confidence match - strong correlation between video and code')
} else if (confidence > 0.4) {
notes.push('Medium confidence - some relevant files found')
} else {
notes.push('Low confidence - limited code correlation found')
}
return notes
}
private extractKeywords(text: string): string[] {
// Extract meaningful keywords from text
const words = text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length > 3)
.filter(
(w) =>
![
'this',
'that',
'with',
'from',
'have',
'been',
'were',
'what',
'when',
'where',
].includes(w)
)
return [...new Set(words)]
}
private formatDuration(seconds?: number): string {
if (!seconds) return 'unknown duration'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
}