update
This commit is contained in:
parent
3b82f4c453
commit
534dead7fe
@ -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,10 +743,13 @@ public function uploadTabScreenshot(Request $request, $id)
|
||||
if (str_contains($base64Image, ',')) {
|
||||
$base64Image = explode(',', $base64Image)[1];
|
||||
}
|
||||
$base64Image = str_replace(' ', '+', $base64Image);
|
||||
$imageData = base64_decode($base64Image);
|
||||
if ($imageData !== false && strlen($imageData) > 0) {
|
||||
file_put_contents($destinationPath . '/' . $filename, $imageData);
|
||||
$url = asset($subDir . '/' . $filename);
|
||||
}
|
||||
}
|
||||
|
||||
if ($url) {
|
||||
$relativePath = '/' . $subDir . '/' . $filename;
|
||||
@ -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']);
|
||||
|
||||
@ -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 |
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user