This commit is contained in:
arys
2026-03-05 03:43:13 +05:00
parent 3936d67285
commit f5f449c4f7
2 changed files with 246 additions and 78 deletions
@@ -46,11 +46,10 @@ public class GeminiVideoGenerationService {
public GeminiVideoGenerationService() {
HttpClient httpClient = HttpClient.create()
.resolver(DefaultAddressResolverGroup.INSTANCE)
.responseTimeout(Duration.ofMinutes(15));
.responseTimeout(Duration.ofMinutes(20));
// Лимит увеличен до 300МБ, чтобы 4K видео точно поместилось в памяти
ExchangeStrategies strategies = ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(300 * 1024 * 1024))
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(500 * 1024 * 1024)) // 500MB
.build();
this.webClient = WebClient.builder()
@@ -67,30 +66,38 @@ public class GeminiVideoGenerationService {
try (InputStream is = resource.getInputStream()) {
this.credentials = GoogleCredentials.fromStream(is)
.createScoped(Collections.singletonList("https://www.googleapis.com/auth/cloud-platform"));
logger.info("VEO: Учетные данные загружены. Модель={}", model);
logger.info("VEO: Credentials loaded. Project={}, Location={}, Model={}", projectId, location, model);
}
} else {
logger.error("VEO: КРИТИЧЕСКАЯ ОШИБКА. Файл {} не найден!", CREDENTIALS_FILE_PATH);
logger.error("VEO: CRITICAL ERROR. File {} not found!", CREDENTIALS_FILE_PATH);
}
} catch (IOException e) {
logger.error("VEO: Ошибка чтения ключа: {}", e.getMessage());
logger.error("VEO: Error reading credentials: {}", e.getMessage());
}
}
private String getValidAccessToken() {
if (this.credentials == null) return null;
if (this.credentials == null) {
logger.error("VEO: Credentials not initialized!");
return null;
}
try {
this.credentials.refreshIfExpired();
return this.credentials.getAccessToken().getTokenValue();
String token = this.credentials.getAccessToken().getTokenValue();
if (token == null || token.isEmpty()) {
logger.error("VEO: Access token is null/empty after refresh!");
return null;
}
return token;
} catch (IOException e) {
logger.error("VEO: Ошибка обновления токена: {}", e.getMessage());
logger.error("VEO: Token refresh error: {}", 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 is not configured!");
return null;
}
@@ -98,122 +105,286 @@ public class GeminiVideoGenerationService {
if (accessToken == null) return null;
try {
// ШАГ 1: Запуск генерации
// ============================================================
// ШАГ 1: Запускаем генерацию через predictLongRunning
// ============================================================
String generateEndpoint = String.format(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning",
location, projectId, location, model
);
location, projectId, location, model);
// Структура запроса для Veo 2.0
Map<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt);
// Оставляем ТОЛЬКО формат видео. Лишние параметры крашат Google API.
Map<String, Object> parameters = new HashMap<>();
parameters.put("aspectRatio", "9:16");
parameters.put("sampleCount", 1);
// Можно добавить: parameters.put("durationSeconds", 8);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("instances", Collections.singletonList(Collections.singletonMap("prompt", prompt)));
requestBody.put("instances", Collections.singletonList(instance));
requestBody.put("parameters", parameters);
logger.info("VEO: Отправка промпта в нейросеть: '{}'", prompt);
logger.info("VEO: Starting generation. Endpoint: {}", generateEndpoint);
logger.info("VEO: Prompt: '{}'", prompt);
Map<String, Object> initResponse = webClient.post()
.uri(URI.create(generateEndpoint))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(120));
if (initResponse == null || !initResponse.containsKey("name")) {
logger.error("VEO: Ошибка: Google не вернул имя операции.");
Map<String, Object> initResponse;
try {
initResponse = webClient.post()
.uri(URI.create(generateEndpoint))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(120));
} catch (WebClientResponseException e) {
logger.error("VEO: HTTP {} on initial request. Body: {}", e.getStatusCode(), e.getResponseBodyAsString());
return null;
}
if (initResponse == null) {
logger.error("VEO: Initial response is null!");
return null;
}
logger.info("VEO: Initial response: {}", objectMapper.writeValueAsString(initResponse));
// Получаем имя операции
String operationName = (String) initResponse.get("name");
logger.info("VEO: Задача поставлена в очередь видеокарт Google.");
if (operationName == null || operationName.isEmpty()) {
logger.error("VEO: No 'name' field in response! Full response: {}", objectMapper.writeValueAsString(initResponse));
return null;
}
// ВАЖНО: Ждем 35 секунд перед первой проверкой, чтобы Google успел создать задачу в своей базе.
// ЭТО УБЕРЕТ ОШИБКУ 404!
logger.info("VEO: Инициализация рендеринга... Ожидание 35 секунд перед проверкой статуса.");
Thread.sleep(35000);
logger.info("VEO: Operation started. Name: {}", operationName);
// ШАГ 2: Мягкий опрос статуса
String fetchEndpoint = String.format("https://%s-aiplatform.googleapis.com/v1/%s", location, operationName);
// ============================================================
// ШАГ 2: Polling через fetchPredictOperation
// ============================================================
String fetchEndpoint = String.format(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation",
location, projectId, location, model);
Map<String, Object> fetchBody = new HashMap<>();
fetchBody.put("operationName", operationName);
int attempts = 0;
int maxAttempts = 80; // 20 минут максимум
int maxAttempts = 120; // 30 минут (120 * 15 сек)
while (attempts < maxAttempts) {
Thread.sleep(15000); // 15 секунд между попытками
attempts++;
logger.info("VEO: Рендеринг видео... Проверка статуса ({} из {}). Пожалуйста, не выключайте сервер!", attempts, maxAttempts);
String currentToken = getValidAccessToken();
if (currentToken == null) continue;
if (currentToken == null) {
logger.warn("VEO: Could not get token on attempt {}, skipping...", attempts);
continue;
}
try {
Map<String, Object> statusResponse = webClient.get()
Map<String, Object> statusResponse = webClient.post()
.uri(URI.create(fetchEndpoint))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + currentToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fetchBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(60));
if (statusResponse == null) continue;
if (statusResponse == null) {
logger.warn("VEO: Null status response on attempt {}", attempts);
continue;
}
// Всегда логируем сырой ответ для диагностики
String rawJson = objectMapper.writeValueAsString(statusResponse);
logger.info("VEO: [Attempt {}/{}] Raw response: {}", attempts, maxAttempts, rawJson);
// Проверяем ошибку в ответе
if (statusResponse.containsKey("error")) {
logger.error("VEO: ОШИБКА ВНУТРИ GOOGLE: {}", objectMapper.writeValueAsString(statusResponse.get("error")));
Object errorObj = statusResponse.get("error");
logger.error("VEO: Google returned error: {}", objectMapper.writeValueAsString(errorObj));
return null;
}
if (Boolean.TRUE.equals(statusResponse.get("done"))) {
logger.info("VEO: ВИДЕО УСПЕШНО СГЕНЕРИРОВАНО! Сохраняем файл...");
@SuppressWarnings("unchecked")
Map<String, Object> responseObj = (Map<String, Object>) statusResponse.get("response");
return extractVideoBytes(responseObj);
// Проверяем done
Object doneObj = statusResponse.get("done");
boolean isDone = Boolean.TRUE.equals(doneObj) || "true".equalsIgnoreCase(String.valueOf(doneObj));
if (!isDone) {
logger.info("VEO: Still processing... attempt {}/{}", attempts, maxAttempts);
continue;
}
logger.info("VEO: Operation DONE on attempt {}! Extracting video...", attempts);
// ============================================================
// ШАГ 3: Извлекаем видео из ответа
// ============================================================
// Структура ответа Veo:
// {
// "done": true,
// "response": {
// "@type": "...",
// "predictions": [
// {
// "bytesBase64Encoded": "...", <- вариант 1
// "videoUri": "gs://...", <- вариант 2
// "mimeType": "video/mp4"
// }
// ]
// }
// }
// Достаем объект response
@SuppressWarnings("unchecked")
Map<String, Object> responseObj = (Map<String, Object>) statusResponse.get("response");
if (responseObj == null) {
// Иногда данные могут быть прямо в корне при done=true
logger.warn("VEO: No 'response' field, trying root object...");
responseObj = statusResponse;
}
// Пробуем извлечь base64
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) {
logger.info("VEO: Found base64 video (length={})", b64.length());
return decodeBase64Video(b64);
}
// Пробуем GCS URI
String videoUri = findFirstValue(responseObj, "videoUri");
if (videoUri == null) {
videoUri = findFirstValue(responseObj, "uri");
}
if (videoUri != null && !videoUri.isEmpty()) {
logger.info("VEO: Found video URI: {}", videoUri);
return downloadFromGcs(videoUri, currentToken);
}
// Пробуем найти в predictions напрямую
@SuppressWarnings("unchecked")
List<Map<String, Object>> predictions = (List<Map<String, Object>>) responseObj.get("predictions");
if (predictions != null && !predictions.isEmpty()) {
Map<String, Object> firstPrediction = predictions.get(0);
logger.info("VEO: First prediction keys: {}", firstPrediction.keySet());
b64 = (String) firstPrediction.get("bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) {
return decodeBase64Video(b64);
}
String uri = (String) firstPrediction.get("videoUri");
if (uri == null) uri = (String) firstPrediction.get("uri");
if (uri != null && !uri.isEmpty()) {
return downloadFromGcs(uri, currentToken);
}
}
logger.error("VEO: Video not found in response! Full dump: {}", rawJson);
return null;
} catch (WebClientResponseException e) {
// Глушим любые промежуточные ошибки связи, просто ждем дальше
logger.debug("VEO: Техническая задержка связи с Google ({}). Идем на следующий круг...", e.getStatusCode());
logger.error("VEO: HTTP error on attempt {}: {} - {}",
attempts, e.getStatusCode(), e.getResponseBodyAsString());
if (e.getStatusCode().is4xxClientError()) {
logger.error("VEO: 4xx error - stopping poll to avoid wasting time.");
return null;
}
// При 5xx продолжаем
}
// Ждем 15 секунд перед следующей проверкой
Thread.sleep(15000);
}
logger.error("VEO: Превышено время ожидания рендеринга (20 минут).");
logger.error("VEO: TIMEOUT! Waited {} minutes, Google did not complete.", (maxAttempts * 15 / 60));
return null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("VEO: Thread interrupted during polling");
return null;
} catch (Exception e) {
logger.error("VEO: Фатальная ошибка сервиса: {}", e.getMessage(), e);
logger.error("VEO: Fatal error: {}", e.getMessage(), e);
return null;
}
}
private byte[] extractVideoBytes(Map<String, Object> data) {
if (data == null) return null;
/**
* Декодируем base64 в байты. Чистим строку от пробелов и data URI префиксов.
*/
private byte[] decodeBase64Video(String b64) {
try {
String b64 = findFirstValue(data, "bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) {
logger.info("VEO: Получены байты видео (размер кода: {}). Декодируем...", b64.length());
String cleanB64 = b64.replaceAll("\\s", "");
if (cleanB64.contains("base64,")) {
cleanB64 = cleanB64.substring(cleanB64.indexOf("base64,") + 7);
}
return Base64.getDecoder().decode(cleanB64);
String clean = b64.replaceAll("\\s", "");
if (clean.contains("base64,")) {
clean = clean.substring(clean.indexOf("base64,") + 7);
}
logger.error("VEO: Видео не найдено в ответе! Дамп: {}", objectMapper.writeValueAsString(data));
byte[] bytes = Base64.getDecoder().decode(clean);
logger.info("VEO: Successfully decoded {} bytes from base64", bytes.length);
return bytes;
} catch (Exception e) {
logger.error("VEO: Ошибка извлечения байтов: {}", e.getMessage());
logger.error("VEO: Failed to decode base64: {}", e.getMessage());
return null;
}
return null;
}
/**
* Скачиваем видео с GCS URI.
*
* Два варианта URI:
* 1. gs://bucket/path — нужен Storage API
* 2. https://storage.googleapis.com/bucket/path — прямой HTTP
*/
private byte[] downloadFromGcs(String uri, String accessToken) {
try {
String downloadUrl;
if (uri.startsWith("gs://")) {
// Конвертируем gs:// в HTTPS URL
// gs://bucket-name/path/to/file.mp4
// -> https://storage.googleapis.com/bucket-name/path/to/file.mp4
String withoutScheme = uri.substring(5); // убираем "gs://"
downloadUrl = "https://storage.googleapis.com/" + withoutScheme;
logger.info("VEO: Converting GCS URI to HTTPS: {}", downloadUrl);
} else if (uri.startsWith("https://")) {
downloadUrl = uri;
} else {
logger.error("VEO: Unknown URI format: {}", uri);
return null;
}
logger.info("VEO: Downloading video from: {}", downloadUrl);
byte[] videoBytes = webClient.get()
.uri(URI.create(downloadUrl))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve()
.bodyToMono(byte[].class)
.block(Duration.ofMinutes(5));
if (videoBytes != null && videoBytes.length > 0) {
logger.info("VEO: Downloaded {} bytes from GCS", videoBytes.length);
return videoBytes;
} else {
logger.error("VEO: Downloaded empty bytes from GCS URI: {}", uri);
return null;
}
} catch (Exception e) {
logger.error("VEO: Error downloading from GCS {}: {}", uri, e.getMessage());
return null;
}
}
/**
* Рекурсивный поиск значения по ключу в вложенных Map/List структурах.
*/
private String findFirstValue(Object obj, String targetKey) {
if (obj instanceof Map) {
Map<?, ?> map = (Map<?, ?>) obj;
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) obj;
if (map.containsKey(targetKey)) {
Object val = map.get(targetKey);
if (val instanceof String) return (String) val;
if (val instanceof String s && !s.isEmpty()) return s;
}
for (Object value : map.values()) {
String found = findFirstValue(value, targetKey);
@@ -38,7 +38,6 @@ public class MarketingStrategyV3Service {
private final PostingTaskService postingTaskService;
private final MinIOService minIOService;
// Решение проблемы с @Async (Self-Injection)
@Autowired
@Lazy
private MarketingStrategyV3Service self;
@@ -65,7 +64,7 @@ public class MarketingStrategyV3Service {
}
public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, String userId,
List<String> referenceFilenames) {
List<String> referenceFilenames) {
MarketingAnalysisV3Document analysisDoc = analysisRepository.findById(analysisId)
.orElseThrow(() -> new IllegalArgumentException("Analysis V3 not found"));
@@ -85,7 +84,6 @@ public class MarketingStrategyV3Service {
strategy = repository.save(strategy);
// Вызов через прокси для корректной работы @Async
self.processStrategyGenerationAsync(strategy.getId(), analysisDoc, strategy);
return strategy;
@@ -93,7 +91,7 @@ public class MarketingStrategyV3Service {
@Async("reportGenerationExecutor")
public void processStrategyGenerationAsync(String strategyId, MarketingAnalysisV3Document analysis,
MarketingStrategy strategy) {
MarketingStrategy strategy) {
try {
strategy.setStatus("processing");
addStatusHistoryEntry(strategy, "processing", "Расчет баллов, генерация постов (минимум 3 в неделю)...");
@@ -163,13 +161,12 @@ public class MarketingStrategyV3Service {
List<Map<String, Object>> postCalendarList = new ArrayList<>();
List<PostingTask> tasks = postingTaskService.getStrategyTasks(strategyId);
// ИСПРАВЛЕНИЕ: Выдаем postIndex явно для фронтенда
List<MarketingStrategy.PostCalendarItem> calendarItems = strategy.getPostCalendar();
for (int i = 0; i < calendarItems.size(); i++) {
MarketingStrategy.PostCalendarItem item = calendarItems.get(i);
Map<String, Object> dtoItem = new HashMap<>();
dtoItem.put("postIndex", i); // ЯВНЫЙ ИНДЕКС
dtoItem.put("postIndex", i);
dtoItem.put("publishDate", item.getPublishDate());
dtoItem.put("platform", item.getPlatform());
dtoItem.put("contentType", item.getContentType());
@@ -254,7 +251,6 @@ public class MarketingStrategyV3Service {
return item;
}
// ИСПРАВЛЕНИЕ: Новый АСИНХРОННЫЙ метод для регенерации видео без таймаутов
@Async("reportGenerationExecutor")
public void regeneratePostVideoAsync(String strategyId, int postIndex) {
try {
@@ -275,7 +271,6 @@ public class MarketingStrategyV3Service {
MarketingStrategy.PostCalendarItem item = postCalendar.get(postIndex);
item.setContentType("видео");
// Ставим заглушку для фронтенда
item.setVideoUrl("generating...");
item.setVideoFilename(null);
item.setImageUrl(null);
@@ -293,7 +288,6 @@ public class MarketingStrategyV3Service {
log.info("Начинаем фоновую регенерацию видео для поста {} в стратегии {}", postIndex, strategyId);
// Этот метод сам скачает, сохранит в MinIO и обновит поля item
doGenerateVideo(item, businessContext);
repository.save(strategy);
@@ -485,10 +479,12 @@ public class MarketingStrategyV3Service {
}
}
// === ВОТ ЗДЕСЬ ОБНОВЛЕННЫЙ ПРОМПТ ДЛЯ ВИДЕО ===
private void doGenerateVideo(MarketingStrategy.PostCalendarItem item, String businessContext) {
String theme = item.getTheme() != null ? item.getTheme() : "Commercial content";
// Изменили промпт: убрали 4K, добавили стиль TikTok/Instagram Reels, сделали его более оптимизированным
String videoPrompt = String.format(
"High quality cinematic commercial video for %s. Scenario: %s. Photorealistic, 4k, professional motion, advertisement style.",
"High quality engaging vertical video for %s. Scenario: %s. Photorealistic, bright lighting, trendy TikTok and Instagram Reels style, dynamic motion.",
businessContext, theme);
try {
@@ -516,9 +512,10 @@ public class MarketingStrategyV3Service {
item.setVideoUrl(null);
}
}
// ===============================================
private void doGenerateImage(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName,
byte[] clientRefBytes) {
byte[] clientRefBytes) {
String imagePrompt = buildImagePrompt(item, businessContext, brandName);
try {
byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, clientRefBytes);