cinema-studio-pro/backend/kling_api.php
Simeon.Schecter 35d19e3a25 Add Kling 3.0 models (V3 and V3 Omni) to video generation
- Add kling-v3 and kling-v3-omni to model selector and backend validation
- Set V3 as the new default model (was V2.6)
- V3 Omni includes built-in audio generation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:30:00 -04:00

628 lines
20 KiB
PHP

<?php
/**
* Kling AI Video Generation API for Lux Studio
* Handles Kling video generation: Text-to-Video, Image-to-Video, Video Extension, Lip Sync
*/
// Suppress HTML error output to prevent breaking JSON responses
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
set_time_limit(600);
ini_set('memory_limit', '512M');
ini_set('post_max_size', '100M');
ini_set('upload_max_filesize', '100M');
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 KlingAPI {
private $accessKey;
private $secretKey;
private $baseUrl = 'https://api-singapore.klingai.com';
public function __construct($accessKey, $secretKey) {
$this->accessKey = $accessKey;
$this->secretKey = $secretKey;
}
/**
* Generate HS256 JWT token for Kling API authentication
* Pure PHP implementation - no external libraries required
*/
private function generateJWT() {
$header = json_encode(['alg' => 'HS256', 'typ' => 'JWT']);
$now = time();
$payload = json_encode([
'iss' => $this->accessKey,
'exp' => $now + 1800, // 30 minute expiry
'nbf' => $now - 5 // Valid from 5 seconds ago (clock skew tolerance)
]);
$b64Header = $this->base64UrlEncode($header);
$b64Payload = $this->base64UrlEncode($payload);
$signature = hash_hmac('sha256', "$b64Header.$b64Payload", $this->secretKey, true);
$b64Signature = $this->base64UrlEncode($signature);
return "$b64Header.$b64Payload.$b64Signature";
}
/**
* URL-safe Base64 encoding
*/
private function base64UrlEncode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Make an authenticated request to the Kling API
*/
private function makeRequest($method, $endpoint, $payload = null) {
$url = $this->baseUrl . $endpoint;
$jwt = $this->generateJWT();
$headers = [
'Content-Type: application/json',
'Authorization: Bearer ' . $jwt
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 120,
CURLOPT_HTTPHEADER => $headers
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Exception("Kling API connection error: $curlError");
}
$decoded = json_decode($response, true);
if ($httpCode === 401 || (isset($decoded['code']) && $decoded['code'] >= 1000 && $decoded['code'] <= 1004)) {
throw new Exception('Kling API authentication failed. Check your KLING_ACCESS_KEY and KLING_SECRET_KEY in .env');
}
if ($httpCode === 429 || (isset($decoded['code']) && $decoded['code'] >= 1302 && $decoded['code'] <= 1304)) {
throw new Exception('Kling API rate limit or concurrent task limit exceeded. Please wait and try again.');
}
if (isset($decoded['code']) && $decoded['code'] >= 1200 && $decoded['code'] <= 1201) {
$detail = $decoded['message'] ?? 'Unknown parameter error';
throw new Exception("Kling API invalid parameters: $detail");
}
if ($httpCode >= 500) {
throw new Exception('Kling server error. Please try again.');
}
if ($httpCode !== 200 && $httpCode !== 201) {
$msg = $decoded['message'] ?? "HTTP $httpCode";
throw new Exception("Kling API error: $msg");
}
return $decoded;
}
/**
* Text-to-Video generation
*/
public function textToVideo($prompt, $opts = []) {
$payload = [
'prompt' => $prompt,
'model_name' => $opts['model_name'] ?? 'kling-v2-6',
'mode' => $opts['mode'] ?? 'std',
'duration' => $opts['duration'] ?? '5',
'aspect_ratio' => $opts['aspect_ratio'] ?? '16:9',
'cfg_scale' => floatval($opts['cfg_scale'] ?? 0.5)
];
if (!empty($opts['negative_prompt'])) {
$payload['negative_prompt'] = $opts['negative_prompt'];
}
if (!empty($opts['sound']) && $opts['sound'] === 'on') {
$payload['sound'] = 'on';
}
// Camera control - only include if a preset is selected
if (!empty($opts['camera_control']) && $opts['camera_control'] !== 'none') {
$payload['camera_control'] = [
'type' => 'predefined',
'config' => $opts['camera_control']
];
}
error_log("Kling T2V request: model={$payload['model_name']}, mode={$payload['mode']}, duration={$payload['duration']}");
$response = $this->makeRequest('POST', '/v1/videos/text2video', $payload);
return $this->extractTaskInfo($response, 'text2video');
}
/**
* Image-to-Video generation
*/
public function imageToVideo($images, $prompt, $opts = []) {
$payload = [
'model_name' => $opts['model_name'] ?? 'kling-v2-6',
'mode' => $opts['mode'] ?? 'std',
'duration' => $opts['duration'] ?? '5',
'aspect_ratio' => $opts['aspect_ratio'] ?? '16:9',
'cfg_scale' => floatval($opts['cfg_scale'] ?? 0.5)
];
if (!empty($prompt)) {
$payload['prompt'] = $prompt;
}
if (!empty($opts['negative_prompt'])) {
$payload['negative_prompt'] = $opts['negative_prompt'];
}
if (!empty($opts['sound']) && $opts['sound'] === 'on') {
$payload['sound'] = 'on';
}
// Camera control
if (!empty($opts['camera_control']) && $opts['camera_control'] !== 'none') {
$payload['camera_control'] = [
'type' => 'predefined',
'config' => $opts['camera_control']
];
}
// Start frame image (required)
if (isset($images[0])) {
$imageData = $this->cleanBase64($images[0]['data']);
$payload['image'] = $imageData;
}
// Optional end frame image
if (isset($images[1])) {
$tailData = $this->cleanBase64($images[1]['data']);
$payload['image_tail'] = $tailData;
}
error_log("Kling I2V request: model={$payload['model_name']}, images=" . count($images));
$response = $this->makeRequest('POST', '/v1/videos/image2video', $payload);
return $this->extractTaskInfo($response, 'image2video');
}
/**
* Extend an existing Kling video
*/
public function extendVideo($videoId, $opts = []) {
$payload = [
'video_id' => $videoId
];
if (!empty($opts['prompt'])) {
$payload['prompt'] = $opts['prompt'];
}
if (!empty($opts['negative_prompt'])) {
$payload['negative_prompt'] = $opts['negative_prompt'];
}
if (isset($opts['cfg_scale'])) {
$payload['cfg_scale'] = floatval($opts['cfg_scale']);
}
error_log("Kling Extend request: videoId=$videoId");
$response = $this->makeRequest('POST', '/v1/videos/video-extend', $payload);
return $this->extractTaskInfo($response, 'video-extend');
}
/**
* Lip Sync generation (image + audio → talking head video)
*/
public function lipSync($imageData, $audioData, $opts = []) {
$payload = [
'image' => $this->cleanBase64($imageData),
'sound_file' => $this->cleanBase64($audioData)
];
if (!empty($opts['prompt'])) {
$payload['prompt'] = $opts['prompt'];
}
if (!empty($opts['mode'])) {
$payload['mode'] = $opts['mode'];
}
error_log("Kling Lip Sync request: mode=" . ($opts['mode'] ?? 'std'));
$response = $this->makeRequest('POST', '/v1/videos/avatar', $payload);
return $this->extractTaskInfo($response, 'avatar');
}
/**
* Check the status of a Kling task
*/
public function checkStatus($taskId, $taskType) {
// Map task types to their status endpoints
$endpointMap = [
'text2video' => '/v1/videos/text2video/',
'image2video' => '/v1/videos/image2video/',
'video-extend' => '/v1/videos/video-extend/',
'avatar' => '/v1/videos/avatar/'
];
$endpoint = ($endpointMap[$taskType] ?? '/v1/videos/text2video/') . $taskId;
$response = $this->makeRequest('GET', $endpoint);
return $response;
}
/**
* Download a video from Kling CDN and save locally
*/
public function downloadAndSaveVideo($url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 120
]);
$videoData = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError || $httpCode !== 200 || !$videoData) {
throw new Exception("Failed to download video from Kling CDN: HTTP $httpCode");
}
$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 $filename;
}
/**
* Extract task ID and type from Kling API response
*/
private function extractTaskInfo($response, $taskType) {
// Kling API returns: { "code": 0, "message": "...", "request_id": "...", "data": { "task_id": "...", "task_status": "submitted" } }
if (isset($response['data']['task_id'])) {
return [
'taskId' => $response['data']['task_id'],
'taskType' => $taskType,
'status' => $response['data']['task_status'] ?? 'submitted'
];
}
// Also check for task_id at top level
if (isset($response['task_id'])) {
return [
'taskId' => $response['task_id'],
'taskType' => $taskType,
'status' => $response['task_status'] ?? 'submitted'
];
}
throw new Exception('Kling API returned unexpected response: ' . json_encode($response));
}
/**
* Clean base64 data - remove data URI prefix and whitespace
*/
private function cleanBase64($data) {
$data = preg_replace('/^data:[^;]+;base64,/', '', $data);
$data = preg_replace('/\s+/', '', $data);
return $data;
}
}
// =============================================================================
// Request Handler
// =============================================================================
try {
$action = $_POST['action'] ?? $_GET['action'] ?? null;
if (!$action) {
throw new Exception('No action specified');
}
// Validate Kling credentials
if (!defined('KLING_ACCESS_KEY') || empty(KLING_ACCESS_KEY) || !defined('KLING_SECRET_KEY') || empty(KLING_SECRET_KEY)) {
throw new Exception('Kling API credentials not configured. Please set KLING_ACCESS_KEY and KLING_SECRET_KEY in your .env file.');
}
$api = new KlingAPI(KLING_ACCESS_KEY, KLING_SECRET_KEY);
// =========================================================================
// Action: generate (Text-to-Video or Image-to-Video)
// =========================================================================
if ($action === 'generate') {
$prompt = $_POST['prompt'] ?? '';
$modelName = $_POST['modelName'] ?? 'kling-v2-6';
$duration = $_POST['duration'] ?? '5';
$aspectRatio = $_POST['aspectRatio'] ?? '16:9';
$mode = $_POST['mode'] ?? 'std';
$cfgScale = $_POST['cfgScale'] ?? '0.5';
$sound = $_POST['sound'] ?? 'off';
$negativePrompt = $_POST['negativePrompt'] ?? '';
$cameraControl = $_POST['cameraControl'] ?? 'none';
// Validate model
$validModels = ['kling-v3', 'kling-v3-omni', 'kling-v2-6', 'kling-v2-5-turbo', 'kling-v2-1-master', 'kling-v1-6'];
if (!in_array($modelName, $validModels)) {
$modelName = 'kling-v2-6';
}
// Validate duration
if (!in_array($duration, ['5', '10'])) {
$duration = '5';
}
// Validate aspect ratio
if (!in_array($aspectRatio, ['16:9', '9:16', '1:1'])) {
$aspectRatio = '16:9';
}
// Validate mode
if (!in_array($mode, ['std', 'pro'])) {
$mode = 'std';
}
$opts = [
'model_name' => $modelName,
'mode' => $mode,
'duration' => $duration,
'aspect_ratio' => $aspectRatio,
'cfg_scale' => $cfgScale,
'sound' => $sound,
'negative_prompt' => trim($negativePrompt),
'camera_control' => $cameraControl
];
// Collect reference images
$referenceImages = [];
$refCount = intval($_POST['referenceImageCount'] ?? 0);
for ($i = 0; $i < min($refCount, 2); $i++) {
if (isset($_POST["referenceImage_$i"])) {
$referenceImages[] = [
'data' => $_POST["referenceImage_$i"],
'mime_type' => $_POST["referenceImageType_$i"] ?? 'image/jpeg'
];
}
}
// Branch: T2V or I2V based on reference images
if (!empty($referenceImages)) {
if (empty($prompt)) {
$prompt = ''; // Prompt is optional for I2V
}
$result = $api->imageToVideo($referenceImages, $prompt, $opts);
} else {
if (empty($prompt)) {
throw new Exception('Prompt is required for text-to-video generation');
}
$result = $api->textToVideo($prompt, $opts);
}
echo json_encode([
'success' => true,
'status' => 'pending',
'taskId' => $result['taskId'],
'taskType' => $result['taskType'],
'message' => 'Kling video generation started. Polling for status.'
]);
exit;
}
// =========================================================================
// Action: extend (Video Extension)
// =========================================================================
if ($action === 'extend') {
$videoId = $_POST['videoId'] ?? null;
$prompt = $_POST['prompt'] ?? '';
$cfgScale = $_POST['cfgScale'] ?? '0.5';
$negativePrompt = $_POST['negativePrompt'] ?? '';
if (!$videoId) {
throw new Exception('Video ID is required for video extension');
}
$opts = [
'prompt' => trim($prompt),
'negative_prompt' => trim($negativePrompt),
'cfg_scale' => $cfgScale
];
$result = $api->extendVideo($videoId, $opts);
echo json_encode([
'success' => true,
'status' => 'pending',
'taskId' => $result['taskId'],
'taskType' => 'video-extend',
'message' => 'Video extension started. Polling for status.'
]);
exit;
}
// =========================================================================
// Action: lipsync (Lip Sync Generation)
// =========================================================================
if ($action === 'lipsync') {
$imageData = $_POST['image'] ?? null;
$audioData = $_POST['audioFile'] ?? null;
$prompt = $_POST['prompt'] ?? '';
$mode = $_POST['mode'] ?? 'std';
if (!$imageData) {
throw new Exception('Face image is required for lip sync');
}
if (!$audioData) {
throw new Exception('Audio file is required for lip sync');
}
if (!in_array($mode, ['std', 'pro'])) {
$mode = 'std';
}
$opts = [
'prompt' => trim($prompt),
'mode' => $mode
];
$result = $api->lipSync($imageData, $audioData, $opts);
echo json_encode([
'success' => true,
'status' => 'pending',
'taskId' => $result['taskId'],
'taskType' => 'avatar',
'message' => 'Lip sync generation started. Polling for status.'
]);
exit;
}
// =========================================================================
// Action: check_status (Poll task status for any workflow)
// =========================================================================
if ($action === 'check_status') {
$taskId = $_POST['taskId'] ?? null;
$taskType = $_POST['taskType'] ?? 'text2video';
if (!$taskId) {
throw new Exception('Task ID is required');
}
$response = $api->checkStatus($taskId, $taskType);
// Extract status from response
$taskStatus = $response['data']['task_status'] ?? 'processing';
$taskStatusMsg = $response['data']['task_status_msg'] ?? '';
if ($taskStatus === 'succeed') {
// Task completed - extract video URL
$videos = $response['data']['task_result']['videos'] ?? [];
if (empty($videos)) {
throw new Exception('Kling generation completed but no video was returned');
}
$videoUrl = $videos[0]['url'] ?? null;
$videoDuration = $videos[0]['duration'] ?? null;
if (!$videoUrl) {
throw new Exception('Kling generation completed but video URL is missing');
}
// Download from Kling CDN and save locally (CDN URLs are temporary)
$filename = $api->downloadAndSaveVideo($videoUrl);
echo json_encode([
'success' => true,
'status' => 'complete',
'video' => [
'url' => '/generated_videos/' . $filename,
'filename' => $filename,
'mime_type' => 'video/mp4',
'duration' => $videoDuration
],
'taskId' => $taskId
]);
} elseif ($taskStatus === 'failed') {
throw new Exception('Kling video generation failed: ' . ($taskStatusMsg ?: 'Unknown error'));
} else {
// Still processing (submitted, processing)
echo json_encode([
'success' => true,
'status' => 'pending',
'taskStatus' => $taskStatus,
'message' => 'Kling video generation in progress...'
]);
}
exit;
}
// =========================================================================
// Action: download_video (Proxy download from Kling CDN)
// =========================================================================
if ($action === 'download_video') {
$videoUrl = $_POST['videoUrl'] ?? null;
if (!$videoUrl) {
throw new Exception('Video URL is required');
}
$filename = $api->downloadAndSaveVideo($videoUrl);
echo json_encode([
'success' => true,
'video' => [
'url' => '/generated_videos/' . $filename,
'filename' => $filename,
'mime_type' => 'video/mp4'
]
]);
exit;
}
throw new Exception('Invalid action: ' . $action);
} catch (Exception $e) {
http_response_code(500);
error_log("Exception in kling_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;
}