fix
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user