Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions backend/controllers/videoAnalysisController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
const VideoAnalysis = require('../models/VideoAnalysis');
const path = require('path');
const fs = require('fs');

/**
* @desc Upload video and initiate AI analysis
* @route POST /api/video-analysis/upload
* @access Private
*/
const uploadAndAnalyzeVideo = async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, message: 'No video file provided' });
}

const userId = req.user.id;
const videoUrl = `/uploads/videos/${req.file.filename}`;

const newAnalysis = new VideoAnalysis({
userId,
videoUrl,
status: 'processing'
});

const savedAnalysis = await newAnalysis.save();

// Simulate AI analysis pipeline (In production, this would trigger a background job/queue)
// For this implementation, we simulate a delayed response or immediate mock analysis
setTimeout(async () => {
try {
// Mock AI analysis results
savedAnalysis.transcript = "Hello, I am a software engineer with three years of experience in React and Node.js.";
savedAnalysis.eyeContactScore = Math.floor(Math.random() * (95 - 70) + 70);
savedAnalysis.fillerWordCount = Math.floor(Math.random() * 5);
savedAnalysis.speakingPace = 'Optimal';
savedAnalysis.overallFeedback = "Great job maintaining a steady pace. Try to reduce filler words like 'um' and 'uh'. Your eye contact was excellent.";
savedAnalysis.status = 'completed';
await savedAnalysis.save();
} catch (error) {
savedAnalysis.status = 'failed';
await savedAnalysis.save();
}
}, 3000); // Simulate 3 seconds of processing

res.status(201).json({
success: true,
message: 'Video uploaded successfully. Analysis in progress.',
data: savedAnalysis
});
} catch (error) {
console.error('Error uploading video:', error);
res.status(500).json({ success: false, message: 'Server error', error: error.message });
}
};

/**
* @desc Get analysis results for a specific recording
* @route GET /api/video-analysis/:id
* @access Private
*/
const getAnalysisResults = async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;

const analysis = await VideoAnalysis.findOne({ _id: id, userId });

if (!analysis) {
return res.status(404).json({ success: false, message: 'Analysis not found or access denied' });
}

res.status(200).json({ success: true, data: analysis });
} catch (error) {
console.error('Error fetching analysis:', error);
res.status(500).json({ success: false, message: 'Server error', error: error.message });
}
};

/**
* @desc Get all video analyses for a user
* @route GET /api/video-analysis
* @access Private
*/
const getUserAnalyses = async (req, res) => {
try {
const userId = req.user.id;
const analyses = await VideoAnalysis.find({ userId }).sort({ createdAt: -1 });
res.status(200).json({ success: true, count: analyses.length, data: analyses });
} catch (error) {
console.error('Error fetching user analyses:', error);
res.status(500).json({ success: false, message: 'Server error', error: error.message });
}
};

module.exports = {
uploadAndAnalyzeVideo,
getAnalysisResults,
getUserAnalyses
};
48 changes: 48 additions & 0 deletions backend/models/VideoAnalysis.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const mongoose = require('mongoose');

const videoAnalysisSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
videoUrl: {
type: String,
required: [true, 'Video URL is required']
},
transcript: {
type: String,
default: ''
},
eyeContactScore: {
type: Number,
min: 0,
max: 100,
default: 0
},
fillerWordCount: {
type: Number,
default: 0
},
speakingPace: {
type: String,
enum: ['Too Slow', 'Optimal', 'Too Fast'],
default: 'Optimal'
},
overallFeedback: {
type: String,
default: ''
},
status: {
type: String,
enum: ['processing', 'completed', 'failed'],
default: 'processing'
}
}, {
timestamps: true
});

videoAnalysisSchema.index({ userId: 1, createdAt: -1 });

module.exports = mongoose.model('VideoAnalysis', videoAnalysisSchema);
52 changes: 52 additions & 0 deletions backend/routes/videoAnalysisRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const fs = require('fs');
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const {
uploadAndAnalyzeVideo,
getAnalysisResults,
getUserAnalyses
} = require('../controllers/videoAnalysisController');
const { protect } = require('../middlewares/authMiddleware');

// Configure multer for video uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadDir = path.join(__dirname, '../uploads/videos');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, 'video-' + uniqueSuffix + path.extname(file.originalname));
}
});

const fileFilter = (req, file, cb) => {
const allowedTypes = /mp4|webm|ogg/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);

if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only video files (mp4, webm, ogg) are allowed!'));
}
};

const upload = multer({
storage: storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit
fileFilter: fileFilter
});

router.use(protect);

router.post('/upload', upload.single('video'), uploadAndAnalyzeVideo);
router.get('/:id', getAnalysisResults);
router.get('/', getUserAnalyses);

module.exports = router;
184 changes: 184 additions & 0 deletions frontend/src/components/VideoRecorder.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React, { useState, useRef, useEffect } from 'react';
import { Video, StopCircle, RefreshCw, AlertCircle } from 'lucide-react';
import axios from 'axios';
import toast from 'react-hot-toast';

const VideoRecorder = ({ onRecordingComplete }) => {
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [stream, setStream] = useState(null);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);

const mediaRecorderRef = useRef(null);
const chunksRef = useRef([]);
const videoRef = useRef(null);
const timerRef = useRef(null);

useEffect(() => {
return () => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
}
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [stream]);

const startRecording = async () => {
try {
setError('');
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
setStream(mediaStream);

if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
}

const mediaRecorder = new MediaRecorder(mediaStream);
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];

mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};

mediaRecorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: 'video/webm' });
await uploadVideo(blob);
};

mediaRecorder.start();
setIsRecording(true);
setRecordingTime(0);

timerRef.current = setInterval(() => {
setRecordingTime(prev => prev + 1);
}, 1000);

} catch (err) {
console.error('Error accessing media devices:', err);
setError('Unable to access camera/microphone. Please check permissions.');
}
};

const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
mediaRecorderRef.current.stop();
setIsRecording(false);
clearInterval(timerRef.current);

if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
}
};

const uploadVideo = async (blob) => {
setUploading(true);
try {
const formData = new FormData();
formData.append('video', blob, 'recording.webm');

const response = await axios.post(
`${import.meta.env.VITE_BACKEND_URL}/video-analysis/upload`,
formData,
{
withCredentials: true,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
// Could add a progress bar here
}
}
);

if (response.data.success) {
toast.success('Video uploaded! Analyzing...');
if (onRecordingComplete) {
onRecordingComplete(response.data.data);
}
}
} catch (error) {
console.error('Upload error:', error);
toast.error(error.response?.data?.message || 'Failed to upload video');
} finally {
setUploading(false);
chunksRef.current = [];
}
};

const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};

return (
<div className="w-full max-w-2xl mx-auto">
<div className="relative bg-black rounded-xl overflow-hidden aspect-video mb-4 border border-gray-700">
{error ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white p-4 text-center">
<AlertCircle size={48} className="text-red-500 mb-4" />
<p>{error}</p>
<button
onClick={startRecording}
className="mt-4 px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
Retry
</button>
</div>
) : (
<video
ref={videoRef}
autoPlay
muted
playsInline
className={`w-full h-full object-cover ${isRecording ? '' : 'hidden'}`}
/>
)}

{isRecording && (
<div className="absolute top-4 right-4 flex items-center gap-2 bg-red-600/90 text-white px-3 py-1 rounded-full text-sm font-medium animate-pulse">
<div className="w-2 h-2 bg-white rounded-full"></div>
REC {formatTime(recordingTime)}
</div>
)}

{uploading && (
<div className="absolute inset-0 bg-black/80 flex flex-col items-center justify-center text-white">
<RefreshCw size={48} className="animate-spin mb-4" />
<p>Uploading and analyzing video...</p>
</div>
)}
</div>

<div className="flex justify-center gap-4">
{!isRecording && !uploading ? (
<button
onClick={startRecording}
className="flex items-center gap-2 px-6 py-3 bg-red-600 text-white rounded-full hover:bg-red-700 transition-colors font-medium shadow-lg"
>
<Video size={20} />
Start Recording
</button>
) : (
<button
onClick={stopRecording}
disabled={uploading}
className="flex items-center gap-2 px-6 py-3 bg-gray-800 text-white rounded-full hover:bg-gray-700 transition-colors font-medium shadow-lg disabled:opacity-50"
>
<StopCircle size={20} />
Stop Recording
</button>
)}
</div>
</div>
);
};

export default VideoRecorder;
Loading
Loading