- Add runtime_config.php: credential store backed by runtime_config.json (gitignored). Falls back to .env values so existing envs need no migration. - Add admin_api.php: status / test_kling / update_kling endpoints gated behind ADMIN_EMAILS allowlist. Accepts Bearer idToken when SSO enabled; uses mock dev@localhost when SSO disabled. - config.php: replace KLING_ACCESS_KEY/SECRET_KEY defines with ADMIN_EMAILS - kling_api.php: read credentials via getKlingCredentials() on every request so rotations take effect immediately without a server restart - All .env templates: add ADMIN_EMAILS= (dev@localhost populated in .env.local) - AdminSettings.jsx: modal with masked status, Test Connection, Save Credentials - AppContent.jsx: admin status check on mount; Settings gear shown to admins - Fix production URL in .env.production/.env.example (optical-prod.oliver.solutions) - .gitignore: exclude backend/runtime_config.json Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
685 lines
24 KiB
PHP
685 lines
24 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 __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/runtime_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) {
|
|
$jsonPayload = json_encode($payload);
|
|
if ($jsonPayload === false) {
|
|
throw new Exception('Failed to encode request payload as JSON: ' . json_last_error_msg());
|
|
}
|
|
error_log("Kling API request to $endpoint: payload size=" . strlen($jsonPayload) . " bytes");
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload);
|
|
}
|
|
}
|
|
|
|
$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'] ?? $decoded['msg'] ?? null;
|
|
if (!$msg && isset($decoded['data']['message'])) {
|
|
$msg = $decoded['data']['message'];
|
|
}
|
|
if (!$msg) {
|
|
$msg = "HTTP $httpCode";
|
|
}
|
|
error_log("Kling API error (HTTP $httpCode) on $method $endpoint: " . substr($response, 0, 2000));
|
|
throw new Exception("Kling API error (HTTP $httpCode): $msg");
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
/**
|
|
* Text-to-Video generation
|
|
*/
|
|
public function textToVideo($prompt, $opts = []) {
|
|
$modelName = $opts['model_name'] ?? 'kling-v3';
|
|
$payload = [
|
|
'prompt' => $prompt,
|
|
'model_name' => $modelName,
|
|
'mode' => $opts['mode'] ?? 'std',
|
|
'duration' => strval(intval($opts['duration'] ?? 5)),
|
|
'aspect_ratio' => $opts['aspect_ratio'] ?? '16:9',
|
|
];
|
|
|
|
// cfg_scale not supported by v2.x models
|
|
if (!preg_match('/^kling-v2/', $modelName)) {
|
|
$payload['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 supported by kling-v1 and kling-v1-5 per capability map
|
|
if (!empty($opts['camera_control']) && $opts['camera_control'] !== 'none'
|
|
&& in_array($modelName, ['kling-v1', 'kling-v1-5'])) {
|
|
$cameraType = $opts['camera_control'];
|
|
// For preset types, config must be null/absent; only 'simple' type uses config
|
|
if ($cameraType === 'simple') {
|
|
$payload['camera_control'] = ['type' => 'simple', 'config' => ['horizontal' => 0, 'vertical' => 0, 'pan' => 0, 'tilt' => 0, 'roll' => 0, 'zoom' => 0]];
|
|
} else {
|
|
$payload['camera_control'] = ['type' => $cameraType];
|
|
}
|
|
}
|
|
|
|
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 = []) {
|
|
$modelName = $opts['model_name'] ?? 'kling-v3';
|
|
$payload = [
|
|
'model_name' => $modelName,
|
|
'mode' => $opts['mode'] ?? 'std',
|
|
'duration' => strval(intval($opts['duration'] ?? 5)),
|
|
'aspect_ratio' => $opts['aspect_ratio'] ?? '16:9',
|
|
];
|
|
|
|
// cfg_scale not supported by v2.x models
|
|
if (!preg_match('/^kling-v2/', $modelName)) {
|
|
$payload['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 only for kling-v1/v1-5; also mutually exclusive with image_tail
|
|
$hasTailFrame = isset($images[1]);
|
|
if (!empty($opts['camera_control']) && $opts['camera_control'] !== 'none'
|
|
&& in_array($modelName, ['kling-v1', 'kling-v1-5'])
|
|
&& !$hasTailFrame) {
|
|
$cameraType = $opts['camera_control'];
|
|
if ($cameraType === 'simple') {
|
|
$payload['camera_control'] = ['type' => 'simple', 'config' => ['horizontal' => 0, 'vertical' => 0, 'pan' => 0, 'tilt' => 0, 'roll' => 0, 'zoom' => 0]];
|
|
} else {
|
|
$payload['camera_control'] = ['type' => $cameraType];
|
|
}
|
|
}
|
|
|
|
// Start frame — field name is `image`, value is clean base64 (no data URI prefix)
|
|
if (isset($images[0])) {
|
|
$payload['image'] = $this->cleanBase64($images[0]['data']);
|
|
}
|
|
|
|
// Optional end frame for A→B interpolation — field name is `image_tail`
|
|
if (isset($images[1])) {
|
|
$payload['image_tail'] = $this->cleanBase64($images[1]['data']);
|
|
}
|
|
|
|
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 (video + audio → lip-synced video)
|
|
* Uses the official /v1/videos/lip-sync endpoint
|
|
*/
|
|
public function lipSync($audioData, $opts = []) {
|
|
$input = [
|
|
'mode' => 'audio2video',
|
|
'audio_type' => 'file',
|
|
'audio_file' => $this->cleanBase64($audioData)
|
|
];
|
|
|
|
// Video source: either video_id (from a previous Kling generation) or video_url
|
|
if (!empty($opts['video_id'])) {
|
|
$input['video_id'] = $opts['video_id'];
|
|
} elseif (!empty($opts['video_url'])) {
|
|
$input['video_url'] = $opts['video_url'];
|
|
}
|
|
|
|
error_log("Kling Lip Sync request: video_id=" . ($opts['video_id'] ?? 'none') . ", video_url=" . (!empty($opts['video_url']) ? 'provided' : 'none'));
|
|
error_log("Kling Lip Sync audio_file length: " . strlen($input['audio_file']));
|
|
|
|
$response = $this->makeRequest('POST', '/v1/videos/lip-sync', ['input' => $input]);
|
|
|
|
return $this->extractTaskInfo($response, 'lip-sync');
|
|
}
|
|
|
|
/**
|
|
* 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/',
|
|
'lip-sync' => '/v1/videos/lip-sync/',
|
|
'avatar' => '/v1/videos/lip-sync/' // Legacy compat
|
|
];
|
|
|
|
$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);
|
|
// Ensure proper base64 padding
|
|
$padding = strlen($data) % 4;
|
|
if ($padding > 0) {
|
|
$data .= str_repeat('=', 4 - $padding);
|
|
}
|
|
return $data;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Request Handler
|
|
// =============================================================================
|
|
|
|
try {
|
|
$action = $_POST['action'] ?? $_GET['action'] ?? null;
|
|
|
|
if (!$action) {
|
|
throw new Exception('No action specified');
|
|
}
|
|
|
|
// Load credentials — runtime JSON first, falls back to .env
|
|
$klingCreds = getKlingCredentials();
|
|
if (empty($klingCreds['access_key']) || empty($klingCreds['secret_key'])) {
|
|
throw new Exception('Kling API credentials not configured. Set KLING_ACCESS_KEY and KLING_SECRET_KEY in .env or rotate them via the admin panel.');
|
|
}
|
|
|
|
$api = new KlingAPI($klingCreds['access_key'], $klingCreds['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', // Latest — support motion control
|
|
'kling-video-o1', // Fast
|
|
'kling-v2-6', 'kling-v2-5-turbo', // V2 series
|
|
'kling-v2-1-master', 'kling-v2-master',
|
|
'kling-v1-6', 'kling-v1-5', 'kling-v1' // V1 series — v1 has camera control
|
|
];
|
|
if (!in_array($modelName, $validModels)) {
|
|
$modelName = 'kling-v3';
|
|
}
|
|
|
|
// Duration: v3/v3-omni support 3-15s; most others only 5s/10s
|
|
$durationInt = intval($duration);
|
|
$flexibleDurationModels = ['kling-v3', 'kling-v3-omni'];
|
|
if (in_array($modelName, $flexibleDurationModels)) {
|
|
if ($durationInt < 3 || $durationInt > 15) { $duration = '5'; }
|
|
} else {
|
|
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') {
|
|
$videoId = $_POST['videoId'] ?? null;
|
|
$videoUrl = $_POST['videoUrl'] ?? null;
|
|
$audioData = $_POST['audioFile'] ?? null;
|
|
|
|
if (!$videoId && !$videoUrl) {
|
|
throw new Exception('A Kling video ID or video URL is required for lip sync');
|
|
}
|
|
|
|
if (!$audioData) {
|
|
throw new Exception('Audio file is required for lip sync');
|
|
}
|
|
|
|
error_log("Lip sync: videoId=$videoId, videoUrl=" . ($videoUrl ? 'yes' : 'no') . ", audioData length=" . strlen($audioData));
|
|
|
|
// Validate the base64 audio data
|
|
$cleanAudio = preg_replace('/^data:[^;]+;base64,/', '', $audioData);
|
|
$cleanAudio = preg_replace('/\s+/', '', $cleanAudio);
|
|
$decoded = base64_decode($cleanAudio, true);
|
|
if ($decoded === false) {
|
|
throw new Exception('Audio file base64 data is invalid or corrupted');
|
|
}
|
|
error_log("Lip sync: decoded audio size=" . strlen($decoded) . " bytes");
|
|
|
|
$opts = [
|
|
'video_id' => $videoId,
|
|
'video_url' => $videoUrl
|
|
];
|
|
|
|
$result = $api->lipSync($audioData, $opts);
|
|
|
|
echo json_encode([
|
|
'success' => true,
|
|
'status' => 'pending',
|
|
'taskId' => $result['taskId'],
|
|
'taskType' => 'lip-sync',
|
|
'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;
|
|
$videoId = $videos[0]['id'] ?? 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' => '/lux-studio/api/generated_videos/' . $filename,
|
|
'filename' => $filename,
|
|
'mime_type' => 'video/mp4',
|
|
'duration' => $videoDuration,
|
|
'videoId' => $videoId
|
|
],
|
|
'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' => '/lux-studio/api/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;
|
|
}
|