Merge pull request 'update' (#14) from subhajit_work_22_07 into main

Reviewed-on: #14
This commit is contained in:
subhajit.pal 2026-08-03 14:36:55 +00:00
commit bc0d249dba
6 changed files with 93 additions and 21 deletions

View File

@ -430,6 +430,8 @@ public function serveStorageFile($path)
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4');
$candidatePaths[] = public_path($baseNoExt . '.webm');
$candidatePaths[] = public_path($baseNoExt . '.mp4');
} }
$filePath = null; $filePath = null;
@ -464,11 +466,50 @@ public function serveStorageFile($path)
$mimeType = @mime_content_type($filePath) ?: 'application/octet-stream'; $mimeType = @mime_content_type($filePath) ?: 'application/octet-stream';
} }
return response()->file($filePath, [ $fileSize = filesize($filePath);
$headers = [
'Content-Type' => $mimeType, 'Content-Type' => $mimeType,
'Accept-Ranges' => 'bytes', 'Accept-Ranges' => 'bytes',
'Cache-Control' => 'public, max-age=86400', 'Cache-Control' => 'public, max-age=86400',
]); ];
// Enable HTTP 206 Partial Content Range support required for HTML5 video playback/seeking
$range = request()->header('Range');
if ($range && preg_match('/bytes=(\d+)-(\d+)?/', $range, $matches)) {
$start = (int) $matches[1];
$end = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : ($fileSize - 1);
if ($start > $end || $start >= $fileSize) {
return response('', 416, ['Content-Range' => "bytes */{$fileSize}"]);
}
$end = min($end, $fileSize - 1);
$length = $end - $start + 1;
$headers['Content-Range'] = "bytes {$start}-{$end}/{$fileSize}";
$headers['Content-Length'] = (string) $length;
return response()->stream(function () use ($filePath, $start, $length) {
$file = fopen($filePath, 'rb');
if ($file) {
fseek($file, $start);
$bufferSize = 1024 * 64;
$bytesLeft = $length;
while ($bytesLeft > 0 && !feof($file)) {
$readSize = min($bufferSize, $bytesLeft);
$data = fread($file, $readSize);
if ($data === false) break;
echo $data;
flush();
$bytesLeft -= strlen($data);
}
fclose($file);
}
}, 206, $headers);
}
$headers['Content-Length'] = (string) $fileSize;
return response()->file($filePath, $headers);
} }
/** /**
@ -503,9 +544,13 @@ private function getFormattedRecordings($interview)
$cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); $cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
$streamUrl = url('/media-stream/' . $cleanPath); $streamUrl = url('/media-stream/' . $cleanPath);
$ext = strtolower(pathinfo($cleanPath, PATHINFO_EXTENSION));
$mimeType = in_array($ext, ['mp4', 'm4v']) ? 'video/mp4' : 'video/webm';
$rec['original_index'] = $idx; $rec['original_index'] = $idx;
$rec['stream_url'] = $streamUrl; $rec['stream_url'] = $streamUrl;
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx; $rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx;
$rec['mime_type'] = $mimeType;
$formatted[] = $rec; $formatted[] = $rec;
} }
@ -680,7 +725,8 @@ public function uploadTabScreenshot(Request $request, $id)
$reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch' $reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch'
$filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg'; $filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg';
$subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id; $uniqueFolder = $interview->submission_unique_id ?: $interview->id;
$subDir = 'uploads/candidate_screenshots/' . $uniqueFolder;
$destinationPath = public_path($subDir); $destinationPath = public_path($subDir);
if (!file_exists($destinationPath)) { if (!file_exists($destinationPath)) {
@ -697,10 +743,13 @@ public function uploadTabScreenshot(Request $request, $id)
if (str_contains($base64Image, ',')) { if (str_contains($base64Image, ',')) {
$base64Image = explode(',', $base64Image)[1]; $base64Image = explode(',', $base64Image)[1];
} }
$base64Image = str_replace(' ', '+', $base64Image);
$imageData = base64_decode($base64Image); $imageData = base64_decode($base64Image);
if ($imageData !== false && strlen($imageData) > 0) {
file_put_contents($destinationPath . '/' . $filename, $imageData); file_put_contents($destinationPath . '/' . $filename, $imageData);
$url = asset($subDir . '/' . $filename); $url = asset($subDir . '/' . $filename);
} }
}
if ($url) { if ($url) {
$relativePath = '/' . $subDir . '/' . $filename; $relativePath = '/' . $subDir . '/' . $filename;
@ -744,6 +793,7 @@ public function uploadTabScreenshot(Request $request, $id)
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'url' => $relativePath, 'url' => $relativePath,
'full_url' => $url,
'ai_verdict' => $aiVerdict, 'ai_verdict' => $aiVerdict,
'total_screenshots' => count($screenshots), 'total_screenshots' => count($screenshots),
]); ]);
@ -787,7 +837,8 @@ public function uploadRecording(Request $request, $id)
$ext = 'webm'; $ext = 'webm';
} }
$subDir = 'uploads/candidate_recordings/' . $interview->submission_unique_id; $uniqueFolder = $interview->submission_unique_id ?: $interview->id;
$subDir = 'uploads/candidate_recordings/' . $uniqueFolder;
$destinationPath = public_path($subDir); $destinationPath = public_path($subDir);
if (!file_exists($destinationPath)) { if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0777, true); mkdir($destinationPath, 0777, true);
@ -796,23 +847,25 @@ public function uploadRecording(Request $request, $id)
$filename = 'recording_' . time() . '.' . $ext; $filename = 'recording_' . time() . '.' . $ext;
$file->move($destinationPath, $filename); $file->move($destinationPath, $filename);
$url = asset($subDir . '/' . $filename); $url = asset($subDir . '/' . $filename);
$relativePath = '/' . $subDir . '/' . $filename;
$recordings = $interview->recordings ?? []; $recordings = $interview->recordings ?? [];
if (!is_array($recordings)) { if (!is_array($recordings)) {
$recordings = $interview->recording_path ? [$interview->recording_path] : []; $recordings = $interview->recording_path ? [$interview->recording_path] : [];
} }
$recordings[] = [ $recordings[] = [
'url' => $url, 'url' => $relativePath,
'full_url' => $url,
'filename' => $filename, 'filename' => $filename,
'created_at' => now()->toIso8601String(), 'created_at' => now()->toIso8601String(),
]; ];
$interview->update([ $interview->update([
'recording_path' => $url, 'recording_path' => $relativePath,
'recordings' => $recordings, 'recordings' => $recordings,
]); ]);
return response()->json(['success' => true, 'path' => $url, 'recordings' => $recordings]); return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]);
} }
return response()->json(['success' => false, 'message' => 'No video payload received']); return response()->json(['success' => false, 'message' => 'No video payload received']);

View File

@ -12,6 +12,9 @@
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'candidate/*',
]);
$middleware->alias([ $middleware->alias([
'role' => \App\Http\Middleware\EnsureRole::class, 'role' => \App\Http\Middleware\EnsureRole::class,
'verify-ms-session' => \App\Http\Middleware\VerifyMicrosoftSession::class, 'verify-ms-session' => \App\Http\Middleware\VerifyMicrosoftSession::class,

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -562,6 +562,7 @@ function submitSolution() {
let latestCallStatus = '{{ $interview->call_status ?? "idle" }}'; let latestCallStatus = '{{ $interview->call_status ?? "idle" }}';
let screenShareVideoElem = null; let screenShareVideoElem = null;
// Auto-initialize persistent offscreen video element for screen share capture
// Auto-initialize persistent offscreen video element for screen share capture // Auto-initialize persistent offscreen video element for screen share capture
function getScreenShareVideoElement() { function getScreenShareVideoElement() {
if (!screenShareVideoElem) { if (!screenShareVideoElem) {
@ -570,10 +571,13 @@ function getScreenShareVideoElement() {
screenShareVideoElem.muted = true; screenShareVideoElem.muted = true;
screenShareVideoElem.playsInline = true; screenShareVideoElem.playsInline = true;
screenShareVideoElem.style.position = 'fixed'; screenShareVideoElem.style.position = 'fixed';
screenShareVideoElem.style.top = '-9999px'; screenShareVideoElem.style.top = '0px';
screenShareVideoElem.style.left = '-9999px'; screenShareVideoElem.style.left = '0px';
screenShareVideoElem.style.width = '1px'; screenShareVideoElem.style.width = '320px';
screenShareVideoElem.style.height = '1px'; screenShareVideoElem.style.height = '180px';
screenShareVideoElem.style.opacity = '0.001';
screenShareVideoElem.style.pointerEvents = 'none';
screenShareVideoElem.style.zIndex = '-9999';
document.body.appendChild(screenShareVideoElem); document.body.appendChild(screenShareVideoElem);
} }
return screenShareVideoElem; return screenShareVideoElem;
@ -595,7 +599,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
// PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Screen share stream) // PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Screen share stream)
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) { if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
const screenTrack = screenShareStream.getVideoTracks()[0]; const screenTrack = screenShareStream.getVideoTracks()[0];
if (screenTrack.readyState === 'live') { if (screenTrack && screenTrack.readyState === 'live') {
try { try {
const vidElem = getScreenShareVideoElement(); const vidElem = getScreenShareVideoElement();
if (vidElem.srcObject !== screenShareStream) { if (vidElem.srcObject !== screenShareStream) {
@ -606,7 +610,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
let rawW = vidElem.videoWidth || 1280; let rawW = vidElem.videoWidth || 1280;
let rawH = vidElem.videoHeight || 720; let rawH = vidElem.videoHeight || 720;
if (rawW > 0 && rawH > 0) { if (vidElem.readyState >= 2 && rawW > 0 && rawH > 0) {
const maxW = 1280; const maxW = 1280;
let targetW = rawW; let targetW = rawW;
let targetH = rawH; let targetH = rawH;
@ -638,8 +642,8 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
if (drewScreenStream) return; if (drewScreenStream) return;
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas // PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas or Composite Fallback
if (typeof html2canvas !== 'undefined') { if (typeof html2canvas !== 'undefined' && !document.hidden) {
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => { html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)'; ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
@ -656,6 +660,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
} }
} catch(e) { } catch(e) {
console.log('Candidate system screenshot capture exception:', e); console.log('Candidate system screenshot capture exception:', e);
fallbackCompositeSnapshot(reason);
} }
} }

View File

@ -1361,6 +1361,9 @@ function pollReviewData() {
recList.forEach((r, displayIdx) => { recList.forEach((r, displayIdx) => {
let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath); let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) {
videoUrl = '/' + videoUrl;
}
if (videoUrl && videoUrl.includes('/storage/')) { if (videoUrl && videoUrl.includes('/storage/')) {
videoUrl = videoUrl.replace('/storage/', '/media-stream/'); videoUrl = videoUrl.replace('/storage/', '/media-stream/');
} }
@ -1368,6 +1371,7 @@ function pollReviewData() {
const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date(); const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date();
const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString(); const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString();
const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${origIdx}`); const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${origIdx}`);
const mimeType = (typeof r === 'object' && r.mime_type) ? r.mime_type : ((videoUrl && videoUrl.includes('.mp4')) ? 'video/mp4' : 'video/webm');
recHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 12px; transition: all 0.2s ease;"> recHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 12px; transition: all 0.2s ease;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
@ -1379,7 +1383,11 @@ function pollReviewData() {
📥 Download Video 📥 Download Video
</a> </a>
</div> </div>
<video src="${videoUrl}" controls preload="metadata" style="width: 100%; max-height: 240px; border-radius: 8px; background: #000; outline: none; border: 1px solid rgba(255,255,255,0.1);"></video> <video controls preload="metadata" style="width: 100%; max-height: 240px; border-radius: 8px; background: #000; outline: none; border: 1px solid rgba(255,255,255,0.1);">
<source src="${videoUrl}" type="${mimeType}">
<source src="${videoUrl}">
Your browser does not support video playback.
</video>
</div>`; </div>`;
}); });
recContainer.innerHTML = recHtml; recContainer.innerHTML = recHtml;
@ -1424,7 +1432,10 @@ function pollReviewData() {
</div><div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;">`; </div><div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;">`;
shotList.forEach((s, idx) => { shotList.forEach((s, idx) => {
const url = typeof s === 'string' ? s : s.url; let url = typeof s === 'string' ? s : (s.url || s.full_url);
if (url && !url.startsWith('http') && !url.startsWith('/')) {
url = '/' + url;
}
const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : ''; const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : '';
shotHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 8px; padding: 8px; text-align: center;"> shotHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 8px; padding: 8px; text-align: center;">
<img src="${url}" style="width: 100%; height: 110px; object-fit: cover; border-radius: 6px; cursor: pointer; border: 1px solid rgba(255,255,255,0.1);" onclick="window.open('${url}', '_blank')" title="Click to view full image" /> <img src="${url}" style="width: 100%; height: 110px; object-fit: cover; border-radius: 6px; cursor: pointer; border: 1px solid rgba(255,255,255,0.1);" onclick="window.open('${url}', '_blank')" title="Click to view full image" />

View File

@ -414,8 +414,8 @@
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none; padding: 3px 8px; background: rgba(99,102,241,0.1); border-radius: 4px;">📥 Download MP4 Video</a> <a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none; padding: 3px 8px; background: rgba(99,102,241,0.1); border-radius: 4px;">📥 Download MP4 Video</a>
</div> </div>
<video controls preload="metadata" style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;"> <video controls preload="metadata" style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;">
<source src="{{ $rec['stream_url'] }}" type="video/mp4"> <source src="{{ $rec['stream_url'] }}" type="{{ $rec['mime_type'] ?? 'video/webm' }}">
<source src="{{ $rec['stream_url'] }}" type="video/webm"> <source src="{{ $rec['stream_url'] }}">
Your browser does not support video playback. Your browser does not support video playback.
</video> </video>
</div> </div>