nb_image_url * 3) RunningHub #1 (1987261617064882178): input + nb_image_url -> first_merge_video_url * 4) WaveSpeed SeedEdit v3 по nb_image_url -> seededit_image_url * 5) KIE.ai Seedance Pro (bytedance/v1-pro-image-to-video) по seededit_image_url -> seedance_video_url * 6) RunningHub #2 (1979945515880189954): first_merge_video_url + seedance_video_url -> final_video_urls[] * * Вызов: * POST /run/index.php?action=pipeline (multipart/form-data, поле `file`) * Ответ: * { ok: true, ... все промежуточные и финальные URLs ... } */ declare(strict_types=1); date_default_timezone_set('Asia/Atyrau'); ignore_user_abort(true); @set_time_limit(1800); // до 30 минут на полный конвейер /* ================== CONFIG ================== */ /* --- ФС --- */ $uploadsDir = __DIR__ . '/../uploads'; if (!is_dir($uploadsDir)) { @mkdir($uploadsDir, 0775, true); } /* --- Общие таймауты --- */ const CONNECT_TIMEOUT = 30; const HTTP_TIMEOUT_SHORT = 60; const HTTP_TIMEOUT_MEDIUM = 300; const HTTP_TIMEOUT_LONG = 900; const TOTAL_WAIT_KIE = 1800; // 30 мин const TOTAL_WAIT_RH = 1800; const TOTAL_WAIT_WS = 900; const POLL_DELAY_MS = 2000; /* --- KIE.AI --- */ const KIE_BASE = 'https://api.kie.ai'; const KIE_CREATE = KIE_BASE . '/api/v1/jobs/createTask'; const KIE_INFO = KIE_BASE . '/api/v1/jobs/recordInfo'; const KIE_API_KEY = 'a820d6e88dc9b29ab90d38955a98c197'; // nano-banana-edit const KIE_NB_MODEL = 'google/nano-banana-edit'; const KIE_NB_PROMPT = 'make extremely luxury sportcar tuning, brutal agressive. do not change color. metallic car, looks fresh sport car. lights on'; const KIE_NB_OUTPUT_FMT = 'png'; const KIE_NB_IMAGE_SIZE = 'auto'; // Seedance Pro (image-to-video) const KIE_SEEDANCE_MODEL = 'bytedance/v1-pro-image-to-video'; const KIE_SEEDANCE_PROMPT = "the car moving fast 200 km/h.\nrain.\ncamera following the car and moving to above from afar."; const KIE_SEEDANCE_RES = '480p'; const KIE_SEEDANCE_DUR = '5'; const KIE_SEEDANCE_CAMERA_FIXED = true; const KIE_SEEDANCE_SEED = -1; const KIE_SEEDANCE_SAFE = false; /* --- WaveSpeed SeedEdit v3 --- */ const WS_BASE = 'https://api.wavespeed.ai'; const WS_SEEDEDIT_ENDPOINT = WS_BASE . '/api/v3/bytedance/seededit-v3'; const WS_PREDICTION_GET = WS_BASE . '/api/v3/predictions'; // we'll append /{id}/result const WS_API_KEY = 'a57420064038dbde8b1f057db572b99bbfba69370c616d394ce103040ddda27d'; // Prompt для SeedEdit (по твоему примеру) const WS_SEEDEDIT_PROMPT = 'keep same position. cinematic style, fast moving on road, fast blur background. night city empty road, night, city lights around. light on. massive rain to the car. high beam headlights. neon lights.'; /* --- RunningHub --- */ const RH_BASE = 'https://www.runninghub.ai'; const RH_RUN = RH_BASE . '/task/openapi/ai-app/run'; const RH_STATUS = RH_BASE . '/task/openapi/status'; const RH_OUTPUTS = RH_BASE . '/task/openapi/outputs'; const RH_API_KEY = '71a11c82b34b44aabf308d7c3f03bf00'; /* RunningHub #1: input+nb -> video */ const RH1_WEBAPP_ID = '1987261617064882178'; const RH1_NODE_INPUT_URL = '171'; const RH1_NODE_NB_URL = '174'; /* RunningHub #2 (финальный merge): video1 + seedance -> финальное видео * Используем URL вместо upload. Ноды как в твоём примере (197 и 80). */ const RH2_WEBAPP_ID = '1979945515880189954'; const RH2_NODE_VIDEO1_URL = '197'; const RH2_NODE_VIDEO2_URL = '80'; /* ================== HELPERS (общие) ================== */ function json_ok(array $d = [], int $code = 200): void { header('Content-Type: application/json; charset=utf-8'); http_response_code($code); echo json_encode(['ok' => true] + $d, JSON_UNESCAPED_UNICODE); exit; } function json_err(string $m, int $code = 400, $details = null): void { header('Content-Type: application/json; charset=utf-8'); http_response_code($code); $p = ['ok' => false, 'error' => $m]; if ($details !== null) { $p['details'] = $details; } echo json_encode($p, JSON_UNESCAPED_UNICODE); exit; } function absolute_url(string $path): string { if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? 'zhasa.online'; if ($path === '' || $path[0] !== '/') { $path = '/' . $path; } return $scheme . '://' . $host . $path; } function http_post_json(string $url, array $payload, int $timeout, array $headers = []): array { $h = array_merge(['Content-Type: application/json'], $headers); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => $h, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE), CURLOPT_CONNECTTIMEOUT => CONNECT_TIMEOUT, CURLOPT_TIMEOUT => $timeout, ]); $raw = curl_exec($ch); $err = curl_error($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [$code, $raw, $err]; } function http_get(string $url, int $timeout, array $headers = []): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => $headers, CURLOPT_CONNECTTIMEOUT => CONNECT_TIMEOUT, CURLOPT_TIMEOUT => $timeout, ]); $raw = curl_exec($ch); $err = curl_error($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [$code, $raw, $err]; } /* ================== KIE.AI HELPERS ================== */ /** * Создать задачу в KIE и вернуть taskId. */ function kie_create_task(string $model, array $input): string { $payload = [ 'model' => $model, 'input' => $input, ]; [$code, $raw, $err] = http_post_json( KIE_CREATE, $payload, HTTP_TIMEOUT_MEDIUM, ['Authorization: Bearer ' . KIE_API_KEY] ); $j = json_decode($raw, true); if ($code !== 200 || !is_array($j) || (int)($j['code'] ?? 0) !== 200) { json_err("KIE createTask failed for model {$model}", 502, $raw ?: $err); } $taskId = $j['data']['taskId'] ?? ''; if ($taskId === '') { json_err("KIE: taskId not returned for model {$model}", 502, $raw); } return $taskId; } /** * Ожидание результата задачи KIE, возвращает массив resultUrls. */ function kie_wait_result(string $taskId, int $totalWaitSeconds = TOTAL_WAIT_KIE): array { $start = time(); $lastState = null; do { $url = KIE_INFO . '?taskId=' . urlencode($taskId); [$code, $raw, $err] = http_get($url, HTTP_TIMEOUT_MEDIUM, [ 'Authorization: Bearer ' . KIE_API_KEY, ]); if ($code === 200 && $raw) { $j = json_decode($raw, true); $state = $j['data']['state'] ?? ''; $lastState = $state; if ($state === 'fail') { $failMsg = $j['data']['failMsg'] ?? 'unknown'; json_err("KIE task {$taskId} failed: {$failMsg}", 500, $raw); } if ($state === 'success') { $resJson = $j['data']['resultJson'] ?? ''; $arr = $resJson ? json_decode($resJson, true) : null; $urls = (is_array($arr) && !empty($arr['resultUrls'])) ? $arr['resultUrls'] : []; if (!$urls) { json_err("KIE task {$taskId} success but resultUrls empty", 502, $raw); } return array_values($urls); } } usleep(POLL_DELAY_MS * 1000); } while (time() - $start < $totalWaitSeconds); json_err("KIE task {$taskId} timeout, last state: " . ($lastState ?? 'unknown'), 504); return []; // unreachable } /** * Удобный helper: запускает модель и возвращает первый resultUrl. */ function kie_run_image_model_and_get_url(string $model, array $input): string { $taskId = kie_create_task($model, $input); $urls = kie_wait_result($taskId); return $urls[0]; } /* ================== WAVESPEED SEEDEDIT HELPERS ================== */ /** * SeedEdit v3: запускает задачу и достаёт первый output URL. */ function wavespeed_seededit_get_url(string $imageUrl, string $prompt): string { $payload = [ 'prompt' => $prompt, 'image' => $imageUrl, 'guidance_scale' => 0.5, 'seed' => -1, 'enable_base64_output'=> false, ]; [$code, $raw, $err] = http_post_json( WS_SEEDEDIT_ENDPOINT, $payload, HTTP_TIMEOUT_MEDIUM, ['Authorization: Bearer ' . WS_API_KEY] ); $j = json_decode($raw, true); if ($code !== 200 || !is_array($j) || (int)($j['code'] ?? 0) !== 200) { json_err('WaveSpeed SeedEdit submit failed', 502, $raw ?: $err); } $data = $j['data'] ?? []; $status = $data['status'] ?? null; $outputs = $data['outputs'] ?? []; // Если уже готово if ($status === 'completed' && !empty($outputs)) { return (string)$outputs[0]; } $id = $data['id'] ?? null; if (!$id) { json_err('WaveSpeed SeedEdit: no prediction id returned', 502, $raw); } $start = time(); $lastStatus = $status ?? 'created'; do { $url = WS_BASE . '/api/v3/predictions/' . urlencode($id) . '/result'; [$cc, $r2, $e2] = http_get($url, HTTP_TIMEOUT_SHORT, [ 'Authorization: Bearer ' . WS_API_KEY, ]); if ($cc === 200 && $r2) { $jr = json_decode($r2, true); $dd = $jr['data'] ?? []; $lastStatus = $dd['status'] ?? $lastStatus; if (($dd['status'] ?? '') === 'failed') { $errMsg = $dd['error'] ?? 'unknown'; json_err("WaveSpeed SeedEdit prediction {$id} failed: {$errMsg}", 500, $r2); } $outs = $dd['outputs'] ?? []; if (($dd['status'] ?? '') === 'completed' && !empty($outs)) { return (string)$outs[0]; } } usleep(POLL_DELAY_MS * 1000); } while (time() - $start < TOTAL_WAIT_WS); json_err("WaveSpeed SeedEdit prediction {$id} timeout, last status: {$lastStatus}", 504); return ''; // unreachable } /* ================== RUNNINGHUB HELPERS ================== */ function rh_ok(array $j): bool { // Обычно code === 0 для успешного ответа return isset($j['code']) && (string)$j['code'] === '0'; } /** * Старт задачи RunningHub и получить taskId. */ function rh_start(string $webappId, array $nodeInfoList): string { $payload = [ 'webappId' => $webappId, 'apiKey' => RH_API_KEY, 'nodeInfoList' => $nodeInfoList, ]; [$code, $raw, $err] = http_post_json(RH_RUN, $payload, HTTP_TIMEOUT_MEDIUM, [ 'Host: www.runninghub.ai', ]); $j = json_decode($raw, true); if ($code !== 200 || !is_array($j) || !rh_ok($j)) { json_err("RunningHub run error (webappId={$webappId})", 502, $raw ?: $err); } $taskId = $j['data']['taskId'] ?? ''; if ($taskId === '') { json_err("RunningHub: taskId not returned (webappId={$webappId})", 502, $raw); } return $taskId; } /** * Ждём завершения задачи RH и возвращаем status (строка). */ function rh_wait_status(string $taskId, int $totalWait = TOTAL_WAIT_RH): string { $start = time(); $last = ''; do { $payload = [ 'apiKey' => RH_API_KEY, 'taskId' => $taskId, ]; [$code, $raw, $err] = http_post_json(RH_STATUS, $payload, HTTP_TIMEOUT_SHORT, [ 'Host: www.runninghub.ai', ]); if ($code === 200 && $raw) { $j = json_decode($raw, true); $data = $j['data'] ?? []; $status = strtoupper($data['taskStatus'] ?? $data['status'] ?? ''); if ($status) $last = $status; if (str_contains($status, 'FAIL')) { json_err("RunningHub task {$taskId} failed", 500, $raw); } if (in_array($status, ['SUCCESS', 'SUCCEED', 'COMPLETED'], true)) { return $status; } } usleep(POLL_DELAY_MS * 1000); } while (time() - $start < $totalWait); json_err("RunningHub task {$taskId} timeout, last status: {$last}", 504); return ''; // unreachable } /** * Получаем выходные URLs из RH. */ function rh_get_outputs(string $taskId): array { $payload = [ 'apiKey' => RH_API_KEY, 'taskId' => $taskId, ]; [$code, $raw, $err] = http_post_json(RH_OUTPUTS, $payload, HTTP_TIMEOUT_LONG, [ 'Host: www.runninghub.ai', ]); $j = json_decode($raw, true); if ($code !== 200 || !is_array($j)) { json_err("RunningHub outputs error ({$taskId})", 502, $raw ?: $err); } $urls = []; if (rh_ok($j) && !empty($j['data']) && is_array($j['data'])) { foreach ($j['data'] as $it) { if (!empty($it['fileUrl'])) { $urls[] = $it['fileUrl']; } } } if (!$urls) { // Некоторые воркфлоу возвращают urls иначе, добавим fallback if (!empty($j['data']['fileUrl'])) { $urls[] = $j['data']['fileUrl']; } if (!$urls) { json_err("RunningHub: no outputs for task {$taskId}", 502, $raw); } } return $urls; } /** * Полный цикл RunningHub: run -> wait -> outputs. */ function rh_run_and_get_urls(string $webappId, array $nodeInfoList): array { $taskId = rh_start($webappId, $nodeInfoList); rh_wait_status($taskId); return rh_get_outputs($taskId); } /* ================== ACTION: PIPELINE ================== */ $action = $_POST['action'] ?? $_GET['action'] ?? ''; if ($action === 'pipeline') { /* 1) Принимаем файл */ if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) { json_err('Файл не получен (ожидается поле "file")', 422); } $f = $_FILES['file']; $ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION)); if (!in_array($ext, ['png', 'jpg', 'jpeg', 'webp'], true)) { json_err('Поддерживаются только PNG/JPG/JPEG/WEBP', 422); } if (($f['size'] ?? 0) > 10 * 1024 * 1024) { json_err('Файл больше 10 МБ', 422); } $name = date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext; $local = $uploadsDir . '/' . $name; if (!move_uploaded_file($f['tmp_name'], $local)) { json_err('Не удалось сохранить файл', 500); } $inputLocalPath = '/uploads/' . $name; $inputUrl = absolute_url($inputLocalPath); /* 2) nano-banana-edit через KIE.AI */ $nb_image_url = kie_run_image_model_and_get_url(KIE_NB_MODEL, [ 'prompt' => KIE_NB_PROMPT, 'image_urls' => [$inputUrl], 'output_format'=> KIE_NB_OUTPUT_FMT, 'image_size' => KIE_NB_IMAGE_SIZE, ]); /* 3) RunningHub #1: input + nb_image_url -> первое видео */ $rh1_urls = rh_run_and_get_urls(RH1_WEBAPP_ID, [ [ 'nodeId' => RH1_NODE_INPUT_URL, 'fieldName' => 'url', 'fieldValue' => $inputUrl, 'description' => 'url_original', ], [ 'nodeId' => RH1_NODE_NB_URL, 'fieldName' => 'url', 'fieldValue' => $nb_image_url, 'description' => 'url_nano_banana', ], ]); $first_merge_video_url = $rh1_urls[0]; /* 4) WaveSpeed SeedEdit v3 по nb_image_url */ $seededit_image_url = wavespeed_seededit_get_url($nb_image_url, WS_SEEDEDIT_PROMPT); /* 5) KIE Seedance Pro (image-to-video) по seededit_image_url */ $seedance_video_urls = kie_wait_result( kie_create_task(KIE_SEEDANCE_MODEL, [ 'prompt' => KIE_SEEDANCE_PROMPT, 'image_url' => $seededit_image_url, 'resolution' => KIE_SEEDANCE_RES, 'duration' => KIE_SEEDANCE_DUR, 'camera_fixed' => KIE_SEEDANCE_CAMERA_FIXED, 'seed' => KIE_SEEDANCE_SEED, 'enable_safety_checker' => KIE_SEEDANCE_SAFE, ]) ); $seedance_video_url = $seedance_video_urls[0]; /* 6) RunningHub #2: merge двух видео по URL (финальный результат) */ $final_urls = rh_run_and_get_urls(RH2_WEBAPP_ID, [ [ 'nodeId' => RH2_NODE_VIDEO1_URL, 'fieldName' => 'url', 'fieldValue' => $first_merge_video_url, 'description' => 'video_from_rh1', ], [ 'nodeId' => RH2_NODE_VIDEO2_URL, 'fieldName' => 'url', 'fieldValue' => $seedance_video_url, 'description' => 'video_from_seedance', ], ]); /* Ответ */ json_ok([ 'input_image' => $inputUrl, 'nano_banana_image' => $nb_image_url, 'runninghub_first_video' => $first_merge_video_url, 'seededit_image' => $seededit_image_url, 'seedance_video' => $seedance_video_url, 'final_videos' => $final_urls, ]); } /* Если прилетел неизвестный action */ json_err('unknown action', 404);