diff --git a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java index a592a72..f5e01d7 100644 --- a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java @@ -7,6 +7,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.ExchangeStrategies; @@ -15,6 +16,8 @@ import org.springframework.web.reactive.function.client.WebClientResponseExcepti import reactor.netty.http.client.HttpClient; import org.springframework.http.client.reactive.ReactorClientHttpConnector; +// ВНИМАНИЕ: Если у тебя Spring Boot 2.x, замени jakarta на javax +import jakarta.annotation.PostConstruct; import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -29,12 +32,16 @@ public class GeminiVideoGenerationService { private final WebClient webClient; + // Кэшируем credentials, чтобы не дергать диск и Google API каждые 15 секунд + private GoogleCredentials credentials; + @Value("${google.cloud.project-id}") private String projectId; @Value("${google.cloud.location:us-central1}") private String location; + // Модель Veo 2.0 @Value("${google.gemini.video.model:veo-2.0-generate-001}") private String model; @@ -44,7 +51,7 @@ public class GeminiVideoGenerationService { .responseTimeout(Duration.ofMinutes(15)); // Долгий таймаут для видео ExchangeStrategies strategies = ExchangeStrategies.builder() - .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(200 * 1024 * 1024)) // 200MB лимит для больших видео + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(200 * 1024 * 1024)) // 200MB .build(); this.webClient = WebClient.builder() @@ -53,19 +60,50 @@ public class GeminiVideoGenerationService { .build(); } + @PostConstruct + public void initCredentials() { + try { + ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH); + if (resource.exists()) { + try (InputStream is = resource.getInputStream()) { + // Читаем ключ 1 раз при старте приложения! + this.credentials = GoogleCredentials.fromStream(is) + .createScoped(Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + logger.info("VEO: Учетные данные Google успешно загружены."); + } + } else { + logger.error("VEO: КРИТИЧЕСКАЯ ОШИБКА. Файл ключа не найден по пути: {}", CREDENTIALS_FILE_PATH); + } + } catch (IOException e) { + logger.error("VEO: Ошибка парсинга ключа Google: {}", e.getMessage()); + } + } + + private String getValidAccessToken() { + if (this.credentials == null) { + logger.error("VEO: Учетные данные не инициализированы. Проверьте google-key.json"); + return null; + } + try { + // Если токен истек, он обновится. Если нет — отдаст текущий. Никакого спама в API. + this.credentials.refreshIfExpired(); + return this.credentials.getAccessToken().getTokenValue(); + } catch (IOException e) { + logger.error("VEO: Ошибка получения/обновления токена: {}", e.getMessage()); + return null; + } + } + public byte[] generateVideo(String prompt) { if (projectId == null || projectId.trim().isEmpty()) { - logger.error("VEO: Не указан Project ID."); + logger.error("VEO: Не указан Project ID (google.cloud.project-id)."); return null; } - try { - String accessToken = getAccessTokenFromResources(); - if (accessToken == null) { - logger.error("VEO: Ошибка токена доступа."); - return null; - } + String accessToken = getValidAccessToken(); + if (accessToken == null) return null; + try { // ШАГ 1: Отправляем запрос на генерацию String generateEndpoint = String.format( "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning", @@ -73,21 +111,14 @@ public class GeminiVideoGenerationService { ); Map requestBody = new HashMap<>(); - List> instances = new ArrayList<>(); - Map instance = new HashMap<>(); - instance.put("prompt", prompt); - instances.add(instance); - requestBody.put("instances", instances); - - Map parameters = new HashMap<>(); - parameters.put("aspectRatio", "9:16"); - requestBody.put("parameters", parameters); + requestBody.put("instances", Collections.singletonList(Collections.singletonMap("prompt", prompt))); + requestBody.put("parameters", Collections.singletonMap("aspectRatio", "9:16")); logger.info("VEO: Запускаем генерацию видео. Промпт: '{}'", prompt); Map initResponse = webClient.post() .uri(URI.create(generateEndpoint)) - .header("Authorization", "Bearer " + accessToken) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .contentType(MediaType.APPLICATION_JSON) .bodyValue(requestBody) .retrieve() @@ -95,40 +126,41 @@ public class GeminiVideoGenerationService { .block(Duration.ofSeconds(30)); if (initResponse == null || !initResponse.containsKey("name")) { - logger.error("VEO: Не удалось получить имя операции от Google. Ответ: {}", initResponse); + logger.error("VEO: Сервер Google не вернул operation name. Ответ: {}", initResponse); return null; } String operationName = (String) initResponse.get("name"); - logger.info("VEO: Задача принята сервером Google. ID операции: {}", operationName); + logger.info("VEO: Задача успешно принята. ID операции: {}", operationName); - // ШАГ 2: Опрос статуса задачи каждые 15 секунд (до 15 минут) - // ИСПОЛЬЗУЕМ v1 ДЛЯ ОПРОСА СТАТУСА (Google починил этот эндпоинт) + // ШАГ 2: Опрос статуса задачи String statusEndpoint = String.format("https://%s-aiplatform.googleapis.com/v1/%s", location, operationName); int attempts = 0; int maxAttempts = 60; // 60 * 15 сек = 15 минут while (attempts < maxAttempts) { - Thread.sleep(15000); + Thread.sleep(15000); // Ожидание 15 секунд attempts++; - logger.info("VEO: Проверка статуса ({} / {})...", attempts, maxAttempts); + logger.info("VEO: Проверка статуса (попытка {} из {})...", attempts, maxAttempts); + + // Берем актуальный токен (обновится сам, если прошло > 1 часа) + String currentToken = getValidAccessToken(); Map statusResponse = webClient.get() .uri(URI.create(statusEndpoint)) - .header("Authorization", "Bearer " + getAccessTokenFromResources()) // Токен нужно обновлять + .header(HttpHeaders.AUTHORIZATION, "Bearer " + currentToken) .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) .block(Duration.ofSeconds(30)); if (statusResponse != null) { if (statusResponse.containsKey("error")) { - logger.error("VEO: Внутренняя ошибка генерации Google: {}", statusResponse.get("error")); + logger.error("VEO: Ошибка внутри процесса генерации: {}", statusResponse.get("error")); return null; } - Boolean isDone = (Boolean) statusResponse.get("done"); - if (Boolean.TRUE.equals(isDone)) { - logger.info("VEO: Видео успешно сгенерировано! Парсим ответ..."); + if (Boolean.TRUE.equals(statusResponse.get("done"))) { + logger.info("VEO: Видео готово! Извлекаем байты..."); @SuppressWarnings("unchecked") Map responseData = (Map) statusResponse.get("response"); return extractVideoBytes(responseData); @@ -140,7 +172,11 @@ public class GeminiVideoGenerationService { return null; } catch (WebClientResponseException e) { - logger.error("VEO API Error! HTTP Status: {}. Ответ: {}", e.getStatusCode(), e.getResponseBodyAsString()); + logger.error("VEO: HTTP Ошибка API Google! Статус: {}. Тело: {}", e.getStatusCode(), e.getResponseBodyAsString()); + return null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // Восстанавливаем статус прерывания + logger.error("VEO: Поток ожидания был прерван", e); return null; } catch (Exception e) { logger.error("VEO: Неизвестная ошибка: {}", e.getMessage(), e); @@ -148,22 +184,6 @@ public class GeminiVideoGenerationService { } } - private String getAccessTokenFromResources() { - try { - ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH); - if (!resource.exists()) return null; - try (InputStream is = resource.getInputStream()) { - GoogleCredentials credentials = GoogleCredentials.fromStream(is) - .createScoped("https://www.googleapis.com/auth/cloud-platform"); - credentials.refreshIfExpired(); - return credentials.getAccessToken().getTokenValue(); - } - } catch (IOException e) { - logger.error("Ошибка чтения ключа: {}", e.getMessage()); - return null; - } - } - @SuppressWarnings("unchecked") private byte[] extractVideoBytes(Map responseData) { if (responseData == null) return null; @@ -172,12 +192,17 @@ public class GeminiVideoGenerationService { if (predictions != null && !predictions.isEmpty()) { Map firstPrediction = predictions.get(0); String base64Video = (String) firstPrediction.get("bytesBase64Encoded"); + if (base64Video != null) { return Base64.getDecoder().decode(base64Video); + } else { + logger.error("VEO: В ответе нет поля bytesBase64Encoded! Структура ответа: {}", firstPrediction); } + } else { + logger.error("VEO: Массив predictions пустой!"); } } catch (Exception e) { - logger.error("VEO: Ошибка парсинга Base64: {}", e.getMessage()); + logger.error("VEO: Ошибка при декодировании Base64 видео: {}", e.getMessage(), e); } return null; }