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