591 lines
22 KiB
PHP
591 lines
22 KiB
PHP
<?php
|
|
/**
|
|
* Video Generation API for Cinema Studio Pro
|
|
* Handles Veo 3.1 video generation via Gemini API
|
|
*/
|
|
|
|
// Suppress HTML error output to prevent breaking JSON responses
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', 0);
|
|
ini_set('log_errors', 1);
|
|
|
|
header('Content-Type: application/json');
|
|
header('Access-Control-Allow-Origin: *');
|
|
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type');
|
|
|
|
// Handle preflight requests
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(200);
|
|
exit;
|
|
}
|
|
|
|
// Load configuration
|
|
require_once 'config.php';
|
|
|
|
class VeoVideoAPI {
|
|
private $apiKey;
|
|
private $baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
private $model;
|
|
|
|
// Available models
|
|
private static $models = [
|
|
'standard' => 'veo-3.1-generate-preview',
|
|
'fast' => 'veo-3.1-fast-generate-preview'
|
|
];
|
|
|
|
// Storage for pending operations
|
|
private $operationsFile;
|
|
|
|
public function __construct($apiKey, $modelType = 'standard') {
|
|
$this->apiKey = $apiKey;
|
|
$this->model = self::$models[$modelType] ?? self::$models['standard'];
|
|
$this->operationsFile = __DIR__ . '/video_operations.json';
|
|
}
|
|
|
|
/**
|
|
* Generate a video using Veo 3.1
|
|
* Returns an operation ID for async polling
|
|
*/
|
|
public function generateVideo($prompt, $duration = 4, $aspectRatio = '16:9', $resolution = '720p', $generateAudio = true, $referenceImages = [], $referenceMode = 'frame') {
|
|
// Build the instance object
|
|
$instance = [
|
|
'prompt' => $prompt
|
|
];
|
|
|
|
// Frame mode: first image is starting frame, optional second image is last frame for interpolation
|
|
if (!empty($referenceImages)) {
|
|
if (isset($referenceImages[0])) {
|
|
$refImg = $referenceImages[0];
|
|
$data = preg_replace('/\s+/', '', $refImg['data']);
|
|
|
|
if (preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $data)) {
|
|
$instance['image'] = [
|
|
'bytesBase64Encoded' => $data,
|
|
'mimeType' => $refImg['mime_type'] ?? 'image/jpeg'
|
|
];
|
|
error_log("Added first frame for I2V generation");
|
|
}
|
|
}
|
|
|
|
// Add last frame for interpolation (when 2 images provided)
|
|
if (count($referenceImages) >= 2 && isset($referenceImages[1])) {
|
|
$lastImg = $referenceImages[1];
|
|
$lastData = preg_replace('/\s+/', '', $lastImg['data']);
|
|
|
|
if (preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $lastData)) {
|
|
$instance['lastFrame'] = [
|
|
'bytesBase64Encoded' => $lastData,
|
|
'mimeType' => $lastImg['mime_type'] ?? 'image/jpeg'
|
|
];
|
|
error_log("Added last frame for video interpolation");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build parameters
|
|
$parameters = [
|
|
'aspectRatio' => $aspectRatio,
|
|
'sampleCount' => 1
|
|
];
|
|
|
|
// Duration: Veo 3.1 supports 4, 6, or 8 seconds
|
|
if (in_array(intval($duration), [4, 6, 8])) {
|
|
$parameters['durationSeconds'] = intval($duration);
|
|
} else {
|
|
$parameters['durationSeconds'] = 4;
|
|
}
|
|
|
|
// Note: generateAudio is handled automatically by Veo 3.1
|
|
// The model generates audio natively based on the scene
|
|
// No need to explicitly pass this parameter
|
|
|
|
$payload = [
|
|
'instances' => [$instance],
|
|
'parameters' => $parameters
|
|
];
|
|
|
|
// Log payload structure (without full base64 data for readability)
|
|
$logPayload = $payload;
|
|
if (isset($logPayload['instances'][0]['image'])) {
|
|
$logPayload['instances'][0]['image']['bytesBase64Encoded'] = '[BASE64_DATA_' . strlen($payload['instances'][0]['image']['bytesBase64Encoded']) . '_bytes]';
|
|
}
|
|
if (isset($logPayload['instances'][0]['lastFrame'])) {
|
|
$logPayload['instances'][0]['lastFrame']['bytesBase64Encoded'] = '[BASE64_DATA_' . strlen($payload['instances'][0]['lastFrame']['bytesBase64Encoded']) . '_bytes]';
|
|
}
|
|
if (isset($logPayload['instances'][0]['referenceImages'])) {
|
|
foreach ($logPayload['instances'][0]['referenceImages'] as $i => &$refImg) {
|
|
$refImg['image']['bytesBase64Encoded'] = '[BASE64_DATA_' . strlen($payload['instances'][0]['referenceImages'][$i]['image']['bytesBase64Encoded']) . '_bytes]';
|
|
}
|
|
}
|
|
error_log("Video generation payload structure: " . json_encode($logPayload));
|
|
|
|
return $this->makeRequest($payload);
|
|
}
|
|
|
|
/**
|
|
* Make API request to Veo predictLongRunning endpoint
|
|
*/
|
|
private function makeRequest($payload, $retryCount = 0) {
|
|
// Veo uses predictLongRunning for async video generation
|
|
$url = "{$this->baseUrl}/{$this->model}:predictLongRunning";
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'x-goog-api-key: ' . $this->apiKey
|
|
],
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_SSL_VERIFYPEER => false, // Disabled for MAMP development
|
|
CURLOPT_TIMEOUT => 600 // 10 minute timeout for video generation
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
|
|
if (curl_errno($ch)) {
|
|
$error = curl_error($ch);
|
|
@curl_close($ch);
|
|
throw new Exception('cURL error: ' . $error);
|
|
}
|
|
|
|
@curl_close($ch);
|
|
|
|
error_log("Video API Response Code: $httpCode");
|
|
error_log("Video API Response: " . substr($response, 0, 1000));
|
|
|
|
if ($httpCode !== 200) {
|
|
$errorData = json_decode($response, true);
|
|
$errorMessage = $errorData['error']['message'] ?? "HTTP $httpCode";
|
|
$errorStatus = $errorData['error']['status'] ?? 'UNKNOWN';
|
|
|
|
error_log("Video API Error - HTTP $httpCode (Status: $errorStatus)");
|
|
error_log("Error message: " . $errorMessage);
|
|
|
|
// Handle specific error types
|
|
if ($httpCode === 500 && stripos($errorMessage, 'internal') !== false && $retryCount < 2) {
|
|
error_log("Retrying video request due to internal error (attempt " . ($retryCount + 1) . ")");
|
|
sleep(5); // Wait 5 seconds before retry
|
|
return $this->makeRequest($payload, $retryCount + 1);
|
|
}
|
|
|
|
if ($httpCode === 429 || $errorStatus === 'RESOURCE_EXHAUSTED') {
|
|
throw new Exception("API rate limit exceeded. Please wait a moment and try again. Video generation is expensive (~\$0.75/second).");
|
|
}
|
|
|
|
if ($errorStatus === 'INVALID_ARGUMENT') {
|
|
throw new Exception("Invalid request format. Check your prompt and settings.");
|
|
}
|
|
|
|
if (stripos($errorMessage, 'model') !== false && stripos($errorMessage, 'not found') !== false) {
|
|
throw new Exception("Veo 3.1 model not available. You may need to enable it in your Google AI Studio account.");
|
|
}
|
|
|
|
throw new Exception("API error: $errorMessage (HTTP $httpCode, Status: $errorStatus)");
|
|
}
|
|
|
|
return json_decode($response, true);
|
|
}
|
|
|
|
/**
|
|
* Extract video data from API response
|
|
*/
|
|
public function extractVideoData($response) {
|
|
error_log("Extracting video data from response: " . json_encode(array_keys($response)));
|
|
|
|
// Check for completed operation with video response (from polling)
|
|
// IMPORTANT: Check this FIRST before the operation check, because completed
|
|
// operations also have a 'name' field but we want to extract the video data
|
|
if (isset($response['done']) && $response['done'] === true) {
|
|
// Check for error in operation
|
|
if (isset($response['error'])) {
|
|
$errorMsg = $response['error']['message'] ?? 'Unknown error';
|
|
throw new Exception("Video generation failed: $errorMsg");
|
|
}
|
|
|
|
// Extract video from generateVideoResponse format
|
|
// Structure: response.generateVideoResponse.generatedSamples[0].video.uri
|
|
$videoResponse = $response['response'] ?? $response;
|
|
error_log("videoResponse keys: " . json_encode(array_keys($videoResponse)));
|
|
|
|
$generateVideoResponse = $videoResponse['generateVideoResponse'] ?? null;
|
|
error_log("generateVideoResponse: " . ($generateVideoResponse ? 'found' : 'null'));
|
|
|
|
if ($generateVideoResponse && isset($generateVideoResponse['generatedSamples'])) {
|
|
$samples = $generateVideoResponse['generatedSamples'];
|
|
error_log("samples count: " . count($samples));
|
|
|
|
if (!empty($samples) && isset($samples[0]['video'])) {
|
|
$video = $samples[0]['video'];
|
|
error_log("video keys: " . json_encode(array_keys($video)));
|
|
|
|
// Check for video URI
|
|
if (isset($video['uri'])) {
|
|
error_log("Found video URI: " . $video['uri']);
|
|
return [
|
|
'url' => $video['uri'],
|
|
'mime_type' => 'video/mp4',
|
|
'type' => 'uri'
|
|
];
|
|
}
|
|
|
|
// Check for inline base64 data
|
|
if (isset($video['bytesBase64Encoded'])) {
|
|
return [
|
|
'base64' => $video['bytesBase64Encoded'],
|
|
'mime_type' => $video['mimeType'] ?? 'video/mp4',
|
|
'type' => 'inline'
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for long-running operation (initial response from predictLongRunning)
|
|
// This is for when the operation is NOT yet complete
|
|
if (isset($response['name']) && strpos($response['name'], 'operations/') !== false) {
|
|
// Only return operation type if not done
|
|
if (!isset($response['done']) || $response['done'] !== true) {
|
|
return [
|
|
'operationId' => $response['name'],
|
|
'type' => 'operation',
|
|
'done' => $response['done'] ?? false
|
|
];
|
|
}
|
|
}
|
|
|
|
// Legacy format: Check for finish reasons that indicate content issues
|
|
if (isset($response['candidates'][0]['finishReason'])) {
|
|
$finishReason = $response['candidates'][0]['finishReason'];
|
|
$finishMessage = $response['candidates'][0]['finishMessage'] ?? '';
|
|
|
|
if ($finishReason === 'SAFETY') {
|
|
throw new Exception('Video generation blocked by safety filters. Please try a different prompt.');
|
|
}
|
|
|
|
if ($finishReason !== 'STOP' && !empty($finishMessage)) {
|
|
throw new Exception('Video generation failed: ' . $finishMessage);
|
|
}
|
|
}
|
|
|
|
// Legacy format: Look for video data in candidates
|
|
if (isset($response['candidates'][0]['content']['parts'])) {
|
|
foreach ($response['candidates'][0]['content']['parts'] as $part) {
|
|
if (isset($part['inline_data']['data'])) {
|
|
return [
|
|
'base64' => $part['inline_data']['data'],
|
|
'mime_type' => $part['inline_data']['mime_type'] ?? 'video/mp4',
|
|
'type' => 'inline'
|
|
];
|
|
}
|
|
if (isset($part['videoMetadata']['videoUri'])) {
|
|
return [
|
|
'url' => $part['videoMetadata']['videoUri'],
|
|
'mime_type' => 'video/mp4',
|
|
'type' => 'uri'
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$errorDetails = "Response structure: " . json_encode(array_keys($response));
|
|
if (isset($response['response'])) {
|
|
$errorDetails .= " | Response keys: " . json_encode(array_keys($response['response']));
|
|
}
|
|
throw new Exception('No video data found in API response. ' . $errorDetails);
|
|
}
|
|
|
|
/**
|
|
* Check status of a long-running operation
|
|
*/
|
|
public function checkOperationStatus($operationId) {
|
|
$url = "https://generativelanguage.googleapis.com/v1beta/{$operationId}";
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_HTTPHEADER => [
|
|
'x-goog-api-key: ' . $this->apiKey
|
|
],
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_SSL_VERIFYPEER => false, // Disabled for MAMP development
|
|
CURLOPT_TIMEOUT => 30
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
@curl_close($ch);
|
|
|
|
if ($httpCode !== 200) {
|
|
throw new Exception("Failed to check operation status: HTTP $httpCode");
|
|
}
|
|
|
|
return json_decode($response, true);
|
|
}
|
|
|
|
/**
|
|
* Save video to disk
|
|
*/
|
|
public function saveVideo($base64Data, $mimeType = 'video/mp4') {
|
|
$videoDir = __DIR__ . '/generated_videos';
|
|
if (!is_dir($videoDir)) {
|
|
mkdir($videoDir, 0755, true);
|
|
}
|
|
|
|
$extension = $mimeType === 'video/webm' ? 'webm' : 'mp4';
|
|
$filename = 'video_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
|
|
$filepath = $videoDir . '/' . $filename;
|
|
|
|
$decoded = base64_decode($base64Data);
|
|
if ($decoded === false) {
|
|
throw new Exception('Failed to decode video data');
|
|
}
|
|
|
|
file_put_contents($filepath, $decoded);
|
|
|
|
return $filename;
|
|
}
|
|
}
|
|
|
|
// Handle API requests
|
|
try {
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
throw new Exception('Invalid request method');
|
|
}
|
|
|
|
$action = $_POST['action'] ?? null;
|
|
|
|
if (!$action) {
|
|
throw new Exception('No action specified');
|
|
}
|
|
|
|
// Check if API key is configured
|
|
if (!defined('GEMINI_API_KEY') || empty(GEMINI_API_KEY)) {
|
|
throw new Exception('API key not configured. Please set GEMINI_API_KEY in config.php');
|
|
}
|
|
|
|
// Get model type (standard or fast)
|
|
$modelType = $_POST['modelType'] ?? 'standard';
|
|
if (!in_array($modelType, ['standard', 'fast'])) {
|
|
$modelType = 'standard';
|
|
}
|
|
|
|
$api = new VeoVideoAPI(GEMINI_API_KEY, $modelType);
|
|
|
|
// Handle generate action
|
|
if ($action === 'generate') {
|
|
$prompt = $_POST['prompt'] ?? null;
|
|
$duration = intval($_POST['duration'] ?? 4);
|
|
$aspectRatio = $_POST['aspectRatio'] ?? '16:9';
|
|
$resolution = $_POST['resolution'] ?? '720p';
|
|
$generateAudio = ($_POST['generateAudio'] ?? 'true') === 'true';
|
|
$referenceMode = $_POST['referenceMode'] ?? 'frame';
|
|
|
|
// Validate reference mode
|
|
if (!in_array($referenceMode, ['frame', 'subject'])) {
|
|
$referenceMode = 'frame';
|
|
}
|
|
|
|
// Collect reference images (up to 3)
|
|
$referenceImages = [];
|
|
$refCount = intval($_POST['referenceImageCount'] ?? 0);
|
|
for ($i = 0; $i < min($refCount, 3); $i++) {
|
|
if (isset($_POST["referenceImage_$i"])) {
|
|
$referenceImages[] = [
|
|
'data' => $_POST["referenceImage_$i"],
|
|
'mime_type' => $_POST["referenceImageType_$i"] ?? 'image/jpeg'
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!$prompt) {
|
|
throw new Exception('Prompt is required');
|
|
}
|
|
|
|
// Validate duration
|
|
if (!in_array($duration, [4, 6, 8])) {
|
|
$duration = 4;
|
|
}
|
|
|
|
// Validate aspect ratio
|
|
if (!in_array($aspectRatio, ['16:9', '9:16'])) {
|
|
$aspectRatio = '16:9';
|
|
}
|
|
|
|
error_log("Starting video generation: duration=$duration, aspect=$aspectRatio, audio=$generateAudio, refMode=$referenceMode, refCount=" . count($referenceImages));
|
|
|
|
// Generate video
|
|
$response = $api->generateVideo($prompt, $duration, $aspectRatio, $resolution, $generateAudio, $referenceImages, $referenceMode);
|
|
$videoData = $api->extractVideoData($response);
|
|
|
|
// Handle different response types
|
|
if ($videoData['type'] === 'operation') {
|
|
// Long-running operation - return operation ID for polling
|
|
echo json_encode([
|
|
'success' => true,
|
|
'status' => 'pending',
|
|
'operationId' => $videoData['operationId'],
|
|
'message' => 'Video generation started. Poll for status.'
|
|
]);
|
|
} else if ($videoData['type'] === 'inline') {
|
|
// Direct response with video data
|
|
$filename = $api->saveVideo($videoData['base64'], $videoData['mime_type']);
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'status' => 'complete',
|
|
'video' => [
|
|
'filename' => $filename,
|
|
'mime_type' => $videoData['mime_type'],
|
|
'url' => ($_ENV['API_BASE_PATH'] ?? '') . '/stream_video.php?file=' . urlencode($filename)
|
|
]
|
|
]);
|
|
} else if ($videoData['type'] === 'uri') {
|
|
// Video available at URI
|
|
echo json_encode([
|
|
'success' => true,
|
|
'status' => 'complete',
|
|
'video' => [
|
|
'url' => $videoData['url'],
|
|
'mime_type' => $videoData['mime_type']
|
|
]
|
|
]);
|
|
}
|
|
|
|
exit;
|
|
}
|
|
|
|
// Handle check status action
|
|
if ($action === 'check_status') {
|
|
$operationId = $_POST['operationId'] ?? null;
|
|
|
|
if (!$operationId) {
|
|
throw new Exception('Operation ID is required');
|
|
}
|
|
|
|
$status = $api->checkOperationStatus($operationId);
|
|
|
|
if (isset($status['done']) && $status['done'] === true) {
|
|
// Operation complete - extract video
|
|
// Pass the full status object so extractVideoData can find the video data
|
|
$videoData = $api->extractVideoData($status);
|
|
|
|
if ($videoData['type'] === 'inline') {
|
|
$filename = $api->saveVideo($videoData['base64'], $videoData['mime_type']);
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'status' => 'complete',
|
|
'video' => [
|
|
'filename' => $filename,
|
|
'mime_type' => $videoData['mime_type'],
|
|
'url' => ($_ENV['API_BASE_PATH'] ?? '') . '/stream_video.php?file=' . urlencode($filename)
|
|
]
|
|
]);
|
|
} else {
|
|
echo json_encode([
|
|
'success' => true,
|
|
'status' => 'complete',
|
|
'video' => [
|
|
'url' => $videoData['url'] ?? null,
|
|
'mime_type' => $videoData['mime_type']
|
|
]
|
|
]);
|
|
}
|
|
} else {
|
|
// Still processing
|
|
$progress = 0;
|
|
if (isset($status['metadata']['progress'])) {
|
|
$progress = floatval($status['metadata']['progress']) * 100;
|
|
}
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'status' => 'pending',
|
|
'progress' => $progress,
|
|
'message' => 'Video generation in progress...'
|
|
]);
|
|
}
|
|
|
|
exit;
|
|
}
|
|
|
|
// Handle download_video action - proxy the video download with authentication
|
|
if ($action === 'download_video') {
|
|
$videoUrl = $_POST['videoUrl'] ?? $_GET['videoUrl'] ?? null;
|
|
|
|
if (!$videoUrl) {
|
|
throw new Exception('Video URL is required');
|
|
}
|
|
|
|
// Validate URL is from Google APIs
|
|
if (strpos($videoUrl, 'generativelanguage.googleapis.com') === false) {
|
|
throw new Exception('Invalid video URL');
|
|
}
|
|
|
|
// Download the video with API key authentication
|
|
$ch = curl_init($videoUrl);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_HTTPHEADER => [
|
|
'x-goog-api-key: ' . GEMINI_API_KEY
|
|
],
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_SSL_VERIFYPEER => false, // Disabled for MAMP development
|
|
CURLOPT_TIMEOUT => 120
|
|
]);
|
|
|
|
$videoData = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
|
@curl_close($ch);
|
|
|
|
if ($httpCode !== 200 || !$videoData) {
|
|
throw new Exception("Failed to download video: HTTP $httpCode");
|
|
}
|
|
|
|
// Save the video locally
|
|
$filename = 'video_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.mp4';
|
|
$videoDir = __DIR__ . '/generated_videos';
|
|
|
|
if (!is_dir($videoDir)) {
|
|
mkdir($videoDir, 0755, true);
|
|
}
|
|
|
|
$filepath = $videoDir . '/' . $filename;
|
|
file_put_contents($filepath, $videoData);
|
|
|
|
// Return local URL
|
|
echo json_encode([
|
|
'success' => true,
|
|
'video' => [
|
|
'url' => ($_ENV['API_BASE_PATH'] ?? '') . '/stream_video.php?file=' . urlencode($filename),
|
|
'filename' => $filename,
|
|
'mime_type' => 'video/mp4'
|
|
]
|
|
]);
|
|
|
|
exit;
|
|
}
|
|
|
|
throw new Exception('Invalid action');
|
|
|
|
} catch (Exception $e) {
|
|
http_response_code(500);
|
|
|
|
error_log("Exception in video_api.php: " . $e->getMessage());
|
|
error_log("Stack trace: " . $e->getTraceAsString());
|
|
|
|
echo json_encode([
|
|
'success' => false,
|
|
'error' => $e->getMessage(),
|
|
'debug' => [
|
|
'file' => basename($e->getFile()),
|
|
'line' => $e->getLine(),
|
|
'timestamp' => date('Y-m-d H:i:s')
|
|
]
|
|
]);
|
|
exit;
|
|
}
|