argument('file'); if (!file_exists($filePath)) { $this->error("File not found: {$filePath}"); return 1; } if (!is_readable($filePath)) { $this->error("File is not readable: {$filePath}"); return 1; } $this->info("Processing CSV file: {$filePath}"); Log::info("===== ProcessVideoSubmissions START ====="); Log::info("File: {$filePath}"); try { // Read CSV file $csv = Reader::createFromPath($filePath, 'r'); $csv->setHeaderOffset(0); $headers = $csv->getHeader(); $records = $csv->getRecords(); // Convert iterator to array for counting $rowsArray = iterator_to_array($records); $totalRows = count($rowsArray); if ($totalRows === 0) { $this->error("CSV file is empty or invalid"); return 1; } $this->info("Found {$totalRows} rows to process"); Log::info("Total rows: {$totalRows}"); // Normalize headers (trim and lowercase for matching) $normalizedHeaders = []; foreach ($headers as $index => $header) { $normalizedHeaders[strtolower(trim($header))] = $header; } // Required columns - check for id, partition, and formdata $requiredColumns = ['id', 'partition', 'formdata']; $missingColumns = []; foreach ($requiredColumns as $col) { if (!isset($normalizedHeaders[$col])) { $missingColumns[] = $col; } } if (!empty($missingColumns)) { $this->error("Missing required columns: " . implode(', ', $missingColumns)); $this->info("Available columns: " . implode(', ', array_keys($normalizedHeaders))); return 1; } $successCount = 0; $errorCount = 0; $skippedCount = 0; $errors = []; $bar = $this->output->createProgressBar($totalRows); $bar->start(); $rowIndex = 0; foreach ($rowsArray as $row) { $rowIndex++; $bar->advance(); try { // Extract basic fields $submissionId = $this->getCellValue($row, $normalizedHeaders, 'id'); $partition = $this->getCellValue($row, $normalizedHeaders, 'partition'); $formdataRaw = $this->getCellValue($row, $normalizedHeaders, 'formdata'); // Extract optional fields $city = $this->getCellValue($row, $normalizedHeaders, 'city'); $employeeCode = $this->getCellValue($row, $normalizedHeaders, 'employee_code'); $videoWidth = $this->getCellValue($row, $normalizedHeaders, 'video_width'); $videoHeight = $this->getCellValue($row, $normalizedHeaders, 'video_height'); $userId = $this->getCellValue($row, $normalizedHeaders, 'user_id'); $userName = $this->getCellValue($row, $normalizedHeaders, 'user_name'); $userAvatar = $this->getCellValue($row, $normalizedHeaders, 'user_avatar'); // Validate required fields if (empty($submissionId)) { $skippedCount++; $errors[] = "Row " . ($rowIndex + 1) . ": Missing id (submission_id)"; continue; } if (empty($partition)) { $skippedCount++; $errors[] = "Row " . ($rowIndex + 1) . ": Missing partition"; continue; } if (empty($formdataRaw)) { $skippedCount++; $errors[] = "Row " . ($rowIndex + 1) . ": Missing formdata"; continue; } // Parse formdata (could be JSON string or already an array) $formdata = null; if (is_string($formdataRaw)) { // Try to decode as JSON $decoded = json_decode($formdataRaw, true); if (json_last_error() === JSON_ERROR_NONE) { $formdata = $decoded; } else { // If not JSON, try to parse as serialized PHP $formdata = @unserialize($formdataRaw); if ($formdata === false) { $skippedCount++; $errors[] = "Row " . ($rowIndex + 1) . ": Unable to parse formdata"; continue; } } } else { $formdata = $formdataRaw; } // Extract video_url from formdata.upload_video $videoUrl = null; if (is_array($formdata) && isset($formdata['upload_video'])) { $videoUrl = $formdata['upload_video']; } elseif (is_object($formdata) && isset($formdata->upload_video)) { $videoUrl = $formdata->upload_video; } // Extract doctor_name from formdata.orignal_doctor_name $doctorName = null; if (is_array($formdata) && isset($formdata['orignal_doctor_name'])) { $doctorName = $formdata['orignal_doctor_name']; } elseif (is_object($formdata) && isset($formdata->orignal_doctor_name)) { $doctorName = $formdata->orignal_doctor_name; } // Extract city from formdata if not already set if (empty($city)) { if (is_array($formdata) && isset($formdata['city'])) { $city = $formdata['city']; } elseif (is_object($formdata) && isset($formdata->city)) { $city = $formdata->city; } } // Extract employee_code from formdata if not already set if (empty($employeeCode)) { if (is_array($formdata) && isset($formdata['employee_code'])) { $employeeCode = $formdata['employee_code']; } elseif (is_object($formdata) && isset($formdata->employee_code)) { $employeeCode = $formdata->employee_code; } } // Validate extracted fields if (empty($videoUrl)) { $skippedCount++; $errors[] = "Row " . ($rowIndex + 1) . ": Missing upload_video in formdata"; continue; } Log::info("Processing row " . ($rowIndex + 1) . ": submission_id={$submissionId}"); // Get video dimensions if not provided (skip if memory intensive) // MediaConvert can auto-detect dimensions, so this is optional if (empty($videoWidth) || empty($videoHeight)) { // Try to get dimensions using ffprobe on URL directly (without downloading) Log::info("Attempting to get video dimensions for: {$videoUrl}"); $dimensions = $this->getVideoDimensionsFromUrl($videoUrl); Log::info("getVideoDimensionsFromUrl returned: " . print_r($dimensions, true)); if ($dimensions && isset($dimensions['width']) && isset($dimensions['height']) && $dimensions['width'] > 0 && $dimensions['height'] > 0) { $videoWidth = (int)$dimensions['width']; $videoHeight = (int)$dimensions['height']; Log::info("Got video dimensions: {$videoWidth}x{$videoHeight} for {$videoUrl}"); } else { // If we can't get dimensions, set to null and let MediaConvert auto-detect Log::info("Could not get video dimensions for {$videoUrl}, MediaConvert will auto-detect"); $videoWidth = null; $videoHeight = null; } } else { Log::info("Using provided video dimensions: {$videoWidth}x{$videoHeight}"); } Log::info("Before jobParams - videoWidth: " . var_export($videoWidth, true) . ", videoHeight: " . var_export($videoHeight, true)); // Process frame image with doctor name $processedFrameUrl = null; if ($doctorName) { $processedFrameUrl = $this->processFrameImage($doctorName, $city, $videoWidth, $videoHeight, $employeeCode); } // Build user_info $userInfo = null; if ($userId) { $userInfo = [ 'id' => $userId, 'name' => $userName ?: '', 'avatar' => $userAvatar ?: '' ]; } // Extract user_partition from partition (e.g., "rapport-vitalis-1769675014-..." -> "rapport-vitalis") $userPartition = null; if ($partition) { $parts = explode('-', $partition); if (count($parts) >= 2) { $userPartition = $parts[0] . '-' . $parts[1]; } } // Build job parameters $jobParams = [ 'video_id' => "1770791678-532ce634-58ef-4662-89b3-8aad4ddcfcd8", 'video_file_url' => $videoUrl, 'reference_entity' => 'form_submission', 'reference_id' => $submissionId, 'reference_partition' => $partition, 'reference_to_update' => 'processed_video', 'reference_updated_flag' => 'has_video_processed', 'user_info' => $userInfo, 'user_partition' => $userPartition ]; // Only add video dimensions if we have them (MediaConvert can auto-detect) if (!empty($videoWidth) && !empty($videoHeight)) { $jobParams['video_width'] = (int)$videoWidth; $jobParams['video_height'] = (int)$videoHeight; Log::info("Added video dimensions to jobParams: {$videoWidth}x{$videoHeight}"); } else { Log::info("Skipping video dimensions - videoWidth: " . var_export($videoWidth, true) . ", videoHeight: " . var_export($videoHeight, true)); } // Add processed frame URL if available if ($processedFrameUrl) { $jobParams['processed_frame_url'] = $processedFrameUrl; } // Log::info("Calling vgreet/job_create for submission: {$submissionId}"); // Log::info("Job params: " . print_r($jobParams, true)); print_r($jobParams); //Call vgreet job_create API $vgreetJobResp = LambdaRequest::postrequest( 'vgreet', 'job/create', $jobParams ); Log::info("vgreet/job_create response: " . print_r($vgreetJobResp, true)); if (isset($vgreetJobResp->statusCode) && $vgreetJobResp->statusCode == 200) { $successCount++; $this->line("\n✓ Row " . ($rowIndex + 1) . " processed successfully"); } else { $errorCount++; $errorMsg = isset($vgreetJobResp->data) ? $vgreetJobResp->data : 'Unknown error'; $errors[] = "Row " . ($rowIndex + 1) . ": " . $errorMsg; $this->line("\n✗ Row " . ($rowIndex + 1) . " failed: " . $errorMsg); } //exit; } catch (\Exception $e) { $errorCount++; $errorMsg = $e->getMessage(); $errors[] = "Row " . ($rowIndex + 1) . ": " . $errorMsg; Log::error("Error processing row " . ($rowIndex + 1) . ": " . $errorMsg); Log::error($e->getTraceAsString()); } } $bar->finish(); $this->newLine(2); // Display results $this->info("Processing completed!"); $this->info("Successful: {$successCount}"); $this->info("Skipped: {$skippedCount}"); $this->info("Errors: {$errorCount}"); if (!empty($errors) && count($errors) <= 20) { $this->warn("\nErrors:"); foreach ($errors as $error) { $this->line($error); } } elseif (!empty($errors)) { $this->warn("\nFirst 20 errors:"); foreach (array_slice($errors, 0, 20) as $error) { $this->line($error); } $this->warn("... and " . (count($errors) - 20) . " more errors. Check logs for details."); } Log::info("===== ProcessVideoSubmissions END ====="); Log::info("Successful: {$successCount}, Skipped: {$skippedCount}, Errors: {$errorCount}"); return 0; } catch (\Exception $e) { $this->error("Processing failed: " . $e->getMessage()); Log::error('ProcessVideoSubmissions error', [ 'file' => $filePath, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); return 1; } } /** * Get cell value from row by normalized header name */ private function getCellValue($row, $normalizedHeaders, $headerName) { $normalized = strtolower(trim($headerName)); if (isset($normalizedHeaders[$normalized])) { $originalHeader = $normalizedHeaders[$normalized]; // CSV records are associative arrays with original header names as keys return isset($row[$originalHeader]) ? trim($row[$originalHeader]) : null; } return null; } /** * Get video dimensions using ffprobe on URL directly (without downloading) * Also checks for rotation metadata and swaps dimensions if needed */ private function getVideoDimensionsFromUrl($videoUrl) { try { $videoPath = null; if (filter_var($videoUrl, FILTER_VALIDATE_URL)) { // It's a URL, use ffprobe directly $videoPath = $videoUrl; } elseif (file_exists($videoUrl)) { // It's a local file path $videoPath = $videoUrl; } else { return null; } // Single ffprobe command to get dimensions and all rotation metadata in JSON format // Use -show_entries to get only stream info without packets_and_frames $ffprobeCmd = "ffprobe -v error -select_streams v:0 -show_entries stream=width,height:stream_tags=rotate:side_data=rotation,displaymatrix -of json " . escapeshellarg($videoPath); Log::info("Running ffprobe command: {$ffprobeCmd}"); $output = @shell_exec($ffprobeCmd); if (!$output) { Log::warning("ffprobe returned no output for: {$videoPath}"); return null; } Log::info("ffprobe output length: " . strlen($output)); // Try to parse JSON - it might be valid but very large $data = json_decode($output, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::error("JSON decode error: " . json_last_error_msg()); // The JSON might be too large or have issues, try to extract just the streams part // Look for "streams": [ pattern $streamsStart = strpos($output, '"streams"'); if ($streamsStart !== false) { // Find the opening bracket after "streams" $bracketStart = strpos($output, '[', $streamsStart); if ($bracketStart !== false) { // Find the matching closing bracket $depth = 0; $bracketEnd = $bracketStart; for ($i = $bracketStart; $i < strlen($output); $i++) { if ($output[$i] === '[') $depth++; if ($output[$i] === ']') { $depth--; if ($depth === 0) { $bracketEnd = $i; break; } } } if ($bracketEnd > $bracketStart) { // Extract just the streams array $streamsJson = '{"streams":' . substr($output, $bracketStart, $bracketEnd - $bracketStart + 1) . '}'; $data = json_decode($streamsJson, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::error("Still JSON decode error after streams extraction: " . json_last_error_msg()); return null; } } else { return null; } } else { return null; } } else { return null; } } if (!isset($data['streams']) || !is_array($data['streams']) || empty($data['streams'])) { Log::warning("No stream data found in ffprobe output. Data keys: " . implode(', ', array_keys($data ?? []))); return null; } if (!isset($data['streams'][0])) { Log::warning("Streams array is empty"); return null; } $stream = $data['streams'][0]; $width = isset($stream['width']) ? (int)$stream['width'] : null; $height = isset($stream['height']) ? (int)$stream['height'] : null; if (!$width || !$height) { return null; } $originalWidth = $width; $originalHeight = $height; // Check for rotation in multiple places $rotationValue = null; // Check for rotation - prefer side_data over tags as it's more accurate $rotationValue = null; // First check side_data rotation (more accurate) if (isset($stream['side_data_list'])) { foreach ($stream['side_data_list'] as $sideData) { if (isset($sideData['rotation'])) { $rotationValue = (int)$sideData['rotation']; Log::info("Found rotation in side_data: {$rotationValue}°"); break; } } } // Fallback to stream tags if side_data not found if ($rotationValue === null && isset($stream['tags']['rotate'])) { $rotationValue = (int)$stream['tags']['rotate']; Log::info("Found rotation in stream tags: {$rotationValue}°"); } // Debug: Log the full stream data if rotation not found if ($rotationValue === null) { Log::info("No rotation metadata found for video. Original dimensions: {$width}x{$height}"); Log::info("Stream tags: " . json_encode($stream['tags'] ?? [])); Log::info("Side data list: " . json_encode($stream['side_data_list'] ?? [])); } // If rotation is 90 or 270 degrees (or -90 or -270), swap width and height // Common rotation values: 90, 180, 270, -90, -180, -270 if ($rotationValue !== null) { $normalizedRotation = $rotationValue % 360; if ($normalizedRotation < 0) { $normalizedRotation += 360; } Log::info("Rotation value: {$rotationValue}°, Normalized: {$normalizedRotation}°"); // 90 or 270 degrees means portrait orientation - swap dimensions if ($normalizedRotation == 90 || $normalizedRotation == 270) { $temp = $width; $width = $height; $height = $temp; Log::info("Video rotation detected: {$rotationValue}° (normalized: {$normalizedRotation}°), swapped dimensions from {$originalWidth}x{$originalHeight} to {$width}x{$height}"); } else { Log::info("Rotation is {$normalizedRotation}°, no swap needed (dimensions: {$width}x{$height})"); } } else { // If no rotation metadata found, check if height > width (portrait by dimensions) // This is a fallback for videos without rotation metadata if ($height > $width) { Log::info("No rotation metadata found, but video appears portrait ({$width}x{$height})"); } } return [ 'width' => $width, 'height' => $height ]; } catch (\Exception $e) { Log::error("Failed to get video dimensions from URL: " . $e->getMessage()); } return null; } /** * Process frame image with doctor name (similar to customdataandvideoform) */ private function processFrameImage($doctorName, $city, $videoWidth, $videoHeight, $employeeCode = null) { try { $personalisation = [ "portrait_frame_image" => "/vgreet/frames/vitaliss_frame_portrait.png", "landscape_video_dimensions" => [ "width" => 1422, "height" => 800 ], "type" => "video_frame", "portrait_video_dimensions" => [ "width" => 800, "height" => 1422 ], "landscape_frame_image" => "/vgreet/frames/vitaliss_frame_landscape.png" ]; // Determine orientation $orientation = null; $frame_image = null; $frame_width = null; $frame_height = null; $fontSize = 18; if ($videoWidth && $videoHeight) { if ($videoHeight > $videoWidth) { $orientation = 'portrait'; $frame_image = isset($personalisation['portrait_frame_image']) ? (strpos($personalisation['portrait_frame_image'], 's3://') === 0 ? $personalisation['portrait_frame_image'] : "s3://rapportapp" . $personalisation['portrait_frame_image']) : "s3://rapportapp/vgreet/frames/vitaliss_frame_portrait.png"; $frame_width = isset($personalisation['portrait_video_dimensions']['width']) ? $personalisation['portrait_video_dimensions']['width'] : 800; $frame_height = isset($personalisation['portrait_video_dimensions']['height']) ? $personalisation['portrait_video_dimensions']['height'] : 1422; } else { $orientation = 'landscape'; $frame_image = isset($personalisation['landscape_frame_image']) ? (strpos($personalisation['landscape_frame_image'], 's3://') === 0 ? $personalisation['landscape_frame_image'] : "s3://rapportapp" . $personalisation['landscape_frame_image']) : "s3://rapportapp/vgreet/frames/vitaliss_frame_landscape.png"; $frame_width = isset($personalisation['landscape_video_dimensions']['width']) ? $personalisation['landscape_video_dimensions']['width'] : 1422; $frame_height = isset($personalisation['landscape_video_dimensions']['height']) ? $personalisation['landscape_video_dimensions']['height'] : 800; } } if (!$frame_image) { Log::warning("No frame image determined, skipping frame processing"); return null; } // Build doctor name text $doctor_name_text = 'Doctor: ' . $doctorName; if ($city) { $doctor_name_text .= ' | City: ' . $city; } // Set font path $fontPath = storage_path('app/fonts/Poppins-Medium.ttf'); if (!file_exists($fontPath)) { Log::warning("Font file not found: {$fontPath}"); return null; } // Download frame image from S3 $s3_relative_path = str_replace('s3://rapportapp/', '', $frame_image); $frameTmpPath = sys_get_temp_dir() . '/' . uniqid('frame_') . '.png'; try { $frameContent = Storage::disk('s3')->get($s3_relative_path); file_put_contents($frameTmpPath, $frameContent); // Load image with transparency preservation $im = imagecreatefrompng($frameTmpPath); if (!$im) { Log::warning("Could not load image from '{$frameTmpPath}'"); @unlink($frameTmpPath); return null; } // Enable alpha blending and save alpha channel to preserve transparency imagealphablending($im, false); imagesavealpha($im, true); $fontColorRGB = [255, 255, 255]; // white // Get text bounding box to calculate center $bbox = imagettfbbox($fontSize, 0, $fontPath, $doctor_name_text); $textWidth = $bbox[2] - $bbox[0]; $centerX = ($frame_width / 2) - ($textWidth / 2); // Enable alpha blending for text rendering imagealphablending($im, true); // Print text at (centerX, y=50) $color = imagecolorallocate($im, $fontColorRGB[0], $fontColorRGB[1], $fontColorRGB[2]); imagettftext($im, $fontSize, 0, $centerX, 50, $color, $fontPath, $doctor_name_text); // Disable alpha blending before saving to preserve transparency imagealphablending($im, false); imagesavealpha($im, true); // Save new image to temp location with transparency $outputFrame = sys_get_temp_dir() . '/' . uniqid('doctor_frame_') . '.png'; imagepng($im, $outputFrame, 9); // Use compression level 9 imagedestroy($im); Log::info("Doctor name '{$doctor_name_text}' printed on frame: {$outputFrame}"); // Upload processed frame back to S3 $file_doctorname = preg_replace('/[^A-Za-z0-9_-]/', '_', $doctorName); $file_doctorname = trim($file_doctorname, '_'); $processedFrameName = 'processed_' . uniqid() . '_' . $file_doctorname . '.png'; $uploadedPath = Storage::disk('s3')->putFileAs( 'vgreet/frames', new \Illuminate\Http\File($outputFrame), $processedFrameName ); // Get full S3 URL $processedFrameS3Path = env('AWS_URL') . '/' . $uploadedPath; Log::info("Processed frame uploaded to S3: {$processedFrameS3Path}"); // Clean up temp files @unlink($outputFrame); @unlink($frameTmpPath); return $processedFrameS3Path; } catch (\Exception $ex) { Log::error("Failed to fetch or process frame image: " . $ex->getMessage()); @unlink($frameTmpPath ?? ''); return null; } } catch (\Exception $e) { Log::error("Error in processFrameImage: " . $e->getMessage()); return null; } } }