This commit is contained in:
subhajit 2026-08-03 20:05:05 +05:30
parent 3b82f4c453
commit 534dead7fe
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/private/public/' . $baseNoExt . '.webm');
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4');
$candidatePaths[] = public_path($baseNoExt . '.webm');
$candidatePaths[] = public_path($baseNoExt . '.mp4');
}
$filePath = null;
@ -464,11 +466,50 @@ public function serveStorageFile($path)
$mimeType = @mime_content_type($filePath) ?: 'application/octet-stream';
}
return response()->file($filePath, [
$fileSize = filesize($filePath);
$headers = [
'Content-Type' => $mimeType,
'Accept-Ranges' => 'bytes',
'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), '/');
$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['stream_url'] = $streamUrl;
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx;
$rec['mime_type'] = $mimeType;
$formatted[] = $rec;
}
@ -680,7 +725,8 @@ public function uploadTabScreenshot(Request $request, $id)
$reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch'
$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);
if (!file_exists($destinationPath)) {
@ -697,9 +743,12 @@ public function uploadTabScreenshot(Request $request, $id)
if (str_contains($base64Image, ',')) {
$base64Image = explode(',', $base64Image)[1];
}
$base64Image = str_replace(' ', '+', $base64Image);
$imageData = base64_decode($base64Image);
file_put_contents($destinationPath . '/' . $filename, $imageData);
$url = asset($subDir . '/' . $filename);
if ($imageData !== false && strlen($imageData) > 0) {
file_put_contents($destinationPath . '/' . $filename, $imageData);
$url = asset($subDir . '/' . $filename);
}
}
if ($url) {
@ -744,6 +793,7 @@ public function uploadTabScreenshot(Request $request, $id)
return response()->json([
'success' => true,
'url' => $relativePath,
'full_url' => $url,
'ai_verdict' => $aiVerdict,
'total_screenshots' => count($screenshots),
]);
@ -787,7 +837,8 @@ public function uploadRecording(Request $request, $id)
$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);
if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0777, true);
@ -796,23 +847,25 @@ public function uploadRecording(Request $request, $id)
$filename = 'recording_' . time() . '.' . $ext;
$file->move($destinationPath, $filename);
$url = asset($subDir . '/' . $filename);
$relativePath = '/' . $subDir . '/' . $filename;
$recordings = $interview->recordings ?? [];
if (!is_array($recordings)) {
$recordings = $interview->recording_path ? [$interview->recording_path] : [];
}
$recordings[] = [
'url' => $url,
'url' => $relativePath,
'full_url' => $url,
'filename' => $filename,
'created_at' => now()->toIso8601String(),
];
$interview->update([
'recording_path' => $url,
'recording_path' => $relativePath,
'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']);

View File

@ -12,6 +12,9 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'candidate/*',
]);
$middleware->alias([
'role' => \App\Http\Middleware\EnsureRole::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 screenShareVideoElem = null;
// Auto-initialize persistent offscreen video element for screen share capture
// Auto-initialize persistent offscreen video element for screen share capture
function getScreenShareVideoElement() {
if (!screenShareVideoElem) {
@ -570,10 +571,13 @@ function getScreenShareVideoElement() {
screenShareVideoElem.muted = true;
screenShareVideoElem.playsInline = true;
screenShareVideoElem.style.position = 'fixed';
screenShareVideoElem.style.top = '-9999px';
screenShareVideoElem.style.left = '-9999px';
screenShareVideoElem.style.width = '1px';
screenShareVideoElem.style.height = '1px';
screenShareVideoElem.style.top = '0px';
screenShareVideoElem.style.left = '0px';
screenShareVideoElem.style.width = '320px';
screenShareVideoElem.style.height = '180px';
screenShareVideoElem.style.opacity = '0.001';
screenShareVideoElem.style.pointerEvents = 'none';
screenShareVideoElem.style.zIndex = '-9999';
document.body.appendChild(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)
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
const screenTrack = screenShareStream.getVideoTracks()[0];
if (screenTrack.readyState === 'live') {
if (screenTrack && screenTrack.readyState === 'live') {
try {
const vidElem = getScreenShareVideoElement();
if (vidElem.srcObject !== screenShareStream) {
@ -606,7 +610,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
let rawW = vidElem.videoWidth || 1280;
let rawH = vidElem.videoHeight || 720;
if (rawW > 0 && rawH > 0) {
if (vidElem.readyState >= 2 && rawW > 0 && rawH > 0) {
const maxW = 1280;
let targetW = rawW;
let targetH = rawH;
@ -638,8 +642,8 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
if (drewScreenStream) return;
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas
if (typeof html2canvas !== 'undefined') {
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas or Composite Fallback
if (typeof html2canvas !== 'undefined' && !document.hidden) {
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => {
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
@ -656,6 +660,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
}
} catch(e) {
console.log('Candidate system screenshot capture exception:', e);
fallbackCompositeSnapshot(reason);
}
}

View File

@ -1361,6 +1361,9 @@ function pollReviewData() {
recList.forEach((r, displayIdx) => {
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/')) {
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 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 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;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
@ -1379,7 +1383,11 @@ function pollReviewData() {
📥 Download Video
</a>
</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>`;
});
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;">`;
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() : '';
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" />

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>
</div>
<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="video/webm">
<source src="{{ $rec['stream_url'] }}" type="{{ $rec['mime_type'] ?? 'video/webm' }}">
<source src="{{ $rec['stream_url'] }}">
Your browser does not support video playback.
</video>
</div>