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() { public GeminiVideoGenerationService() {
HttpClient httpClient = HttpClient.create() HttpClient httpClient = HttpClient.create()
.resolver(DefaultAddressResolverGroup.INSTANCE) .resolver(DefaultAddressResolverGroup.INSTANCE)
.responseTimeout(Duration.ofMinutes(15)); .responseTimeout(Duration.ofMinutes(20));
// Лимит увеличен до 300МБ, чтобы 4K видео точно поместилось в памяти
ExchangeStrategies strategies = ExchangeStrategies.builder() ExchangeStrategies strategies = ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(300 * 1024 * 1024)) .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(500 * 1024 * 1024)) // 500MB
.build(); .build();
this.webClient = WebClient.builder() this.webClient = WebClient.builder()
@@ -67,30 +66,38 @@ public class GeminiVideoGenerationService {
try (InputStream is = resource.getInputStream()) { try (InputStream is = resource.getInputStream()) {
this.credentials = GoogleCredentials.fromStream(is) this.credentials = GoogleCredentials.fromStream(is)
.createScoped(Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); .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 { } else {
logger.error("VEO: КРИТИЧЕСКАЯ ОШИБКА. Файл {} не найден!", CREDENTIALS_FILE_PATH); logger.error("VEO: CRITICAL ERROR. File {} not found!", CREDENTIALS_FILE_PATH);
} }
} catch (IOException e) { } catch (IOException e) {
logger.error("VEO: Ошибка чтения ключа: {}", e.getMessage()); logger.error("VEO: Error reading credentials: {}", e.getMessage());
} }
} }
private String getValidAccessToken() { private String getValidAccessToken() {
if (this.credentials == null) return null; if (this.credentials == null) {
logger.error("VEO: Credentials not initialized!");
return null;
}
try { try {
this.credentials.refreshIfExpired(); 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) { } catch (IOException e) {
logger.error("VEO: Ошибка обновления токена: {}", e.getMessage()); logger.error("VEO: Token refresh error: {}", e.getMessage());
return null; 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 is not configured!");
return null; return null;
} }
@@ -98,122 +105,286 @@ public class GeminiVideoGenerationService {
if (accessToken == null) return null; if (accessToken == null) return null;
try { try {
// ШАГ 1: Запуск генерации // ============================================================
// ШАГ 1: Запускаем генерацию через predictLongRunning
// ============================================================
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",
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<>(); Map<String, Object> parameters = new HashMap<>();
parameters.put("aspectRatio", "9:16"); parameters.put("aspectRatio", "9:16");
parameters.put("sampleCount", 1);
// Можно добавить: parameters.put("durationSeconds", 8);
Map<String, Object> requestBody = new HashMap<>(); 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); 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() Map<String, Object> initResponse;
.uri(URI.create(generateEndpoint)) try {
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) initResponse = webClient.post()
.contentType(MediaType.APPLICATION_JSON) .uri(URI.create(generateEndpoint))
.bodyValue(requestBody) .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve() .contentType(MediaType.APPLICATION_JSON)
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {}) .bodyValue(requestBody)
.block(Duration.ofSeconds(120)); .retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
if (initResponse == null || !initResponse.containsKey("name")) { .block(Duration.ofSeconds(120));
logger.error("VEO: Ошибка: Google не вернул имя операции."); } catch (WebClientResponseException e) {
logger.error("VEO: HTTP {} on initial request. Body: {}", e.getStatusCode(), e.getResponseBodyAsString());
return null; 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"); 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 успел создать задачу в своей базе. logger.info("VEO: Operation started. Name: {}", operationName);
// ЭТО УБЕРЕТ ОШИБКУ 404!
logger.info("VEO: Инициализация рендеринга... Ожидание 35 секунд перед проверкой статуса.");
Thread.sleep(35000);
// ШАГ 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 attempts = 0;
int maxAttempts = 80; // 20 минут максимум int maxAttempts = 120; // 30 минут (120 * 15 сек)
while (attempts < maxAttempts) { while (attempts < maxAttempts) {
Thread.sleep(15000); // 15 секунд между попытками
attempts++; attempts++;
logger.info("VEO: Рендеринг видео... Проверка статуса ({} из {}). Пожалуйста, не выключайте сервер!", attempts, maxAttempts);
String currentToken = getValidAccessToken(); String currentToken = getValidAccessToken();
if (currentToken == null) continue; if (currentToken == null) {
logger.warn("VEO: Could not get token on attempt {}, skipping...", attempts);
continue;
}
try { try {
Map<String, Object> statusResponse = webClient.get() Map<String, Object> statusResponse = webClient.post()
.uri(URI.create(fetchEndpoint)) .uri(URI.create(fetchEndpoint))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + currentToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + currentToken)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(fetchBody)
.retrieve() .retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {}) .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(60)); .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")) { 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; return null;
} }
if (Boolean.TRUE.equals(statusResponse.get("done"))) { // Проверяем done
logger.info("VEO: ВИДЕО УСПЕШНО СГЕНЕРИРОВАНО! Сохраняем файл..."); Object doneObj = statusResponse.get("done");
@SuppressWarnings("unchecked") boolean isDone = Boolean.TRUE.equals(doneObj) || "true".equalsIgnoreCase(String.valueOf(doneObj));
Map<String, Object> responseObj = (Map<String, Object>) statusResponse.get("response");
return extractVideoBytes(responseObj); 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) { } catch (WebClientResponseException e) {
// Глушим любые промежуточные ошибки связи, просто ждем дальше logger.error("VEO: HTTP error on attempt {}: {} - {}",
logger.debug("VEO: Техническая задержка связи с Google ({}). Идем на следующий круг...", e.getStatusCode()); 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; return null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("VEO: Thread interrupted during polling");
return null;
} catch (Exception e) { } catch (Exception e) {
logger.error("VEO: Фатальная ошибка сервиса: {}", e.getMessage(), e); logger.error("VEO: Fatal error: {}", e.getMessage(), e);
return null; return null;
} }
} }
private byte[] extractVideoBytes(Map<String, Object> data) { /**
if (data == null) return null; * Декодируем base64 в байты. Чистим строку от пробелов и data URI префиксов.
*/
private byte[] decodeBase64Video(String b64) {
try { try {
String b64 = findFirstValue(data, "bytesBase64Encoded"); String clean = b64.replaceAll("\\s", "");
if (b64 != null && !b64.isEmpty()) { if (clean.contains("base64,")) {
logger.info("VEO: Получены байты видео (размер кода: {}). Декодируем...", b64.length()); clean = clean.substring(clean.indexOf("base64,") + 7);
String cleanB64 = b64.replaceAll("\\s", "");
if (cleanB64.contains("base64,")) {
cleanB64 = cleanB64.substring(cleanB64.indexOf("base64,") + 7);
}
return Base64.getDecoder().decode(cleanB64);
} }
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) { } 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) { private String findFirstValue(Object obj, String targetKey) {
if (obj instanceof Map) { if (obj instanceof Map) {
Map<?, ?> map = (Map<?, ?>) obj; @SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) obj;
if (map.containsKey(targetKey)) { if (map.containsKey(targetKey)) {
Object val = map.get(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()) { for (Object value : map.values()) {
String found = findFirstValue(value, targetKey); String found = findFirstValue(value, targetKey);
@@ -38,7 +38,6 @@ public class MarketingStrategyV3Service {
private final PostingTaskService postingTaskService; private final PostingTaskService postingTaskService;
private final MinIOService minIOService; private final MinIOService minIOService;
// Решение проблемы с @Async (Self-Injection)
@Autowired @Autowired
@Lazy @Lazy
private MarketingStrategyV3Service self; private MarketingStrategyV3Service self;
@@ -65,7 +64,7 @@ public class MarketingStrategyV3Service {
} }
public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, String userId, public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, String userId,
List<String> referenceFilenames) { List<String> referenceFilenames) {
MarketingAnalysisV3Document analysisDoc = analysisRepository.findById(analysisId) MarketingAnalysisV3Document analysisDoc = analysisRepository.findById(analysisId)
.orElseThrow(() -> new IllegalArgumentException("Analysis V3 not found")); .orElseThrow(() -> new IllegalArgumentException("Analysis V3 not found"));
@@ -85,7 +84,6 @@ public class MarketingStrategyV3Service {
strategy = repository.save(strategy); strategy = repository.save(strategy);
// Вызов через прокси для корректной работы @Async
self.processStrategyGenerationAsync(strategy.getId(), analysisDoc, strategy); self.processStrategyGenerationAsync(strategy.getId(), analysisDoc, strategy);
return strategy; return strategy;
@@ -93,7 +91,7 @@ public class MarketingStrategyV3Service {
@Async("reportGenerationExecutor") @Async("reportGenerationExecutor")
public void processStrategyGenerationAsync(String strategyId, MarketingAnalysisV3Document analysis, public void processStrategyGenerationAsync(String strategyId, MarketingAnalysisV3Document analysis,
MarketingStrategy strategy) { MarketingStrategy strategy) {
try { try {
strategy.setStatus("processing"); strategy.setStatus("processing");
addStatusHistoryEntry(strategy, "processing", "Расчет баллов, генерация постов (минимум 3 в неделю)..."); addStatusHistoryEntry(strategy, "processing", "Расчет баллов, генерация постов (минимум 3 в неделю)...");
@@ -163,13 +161,12 @@ public class MarketingStrategyV3Service {
List<Map<String, Object>> postCalendarList = new ArrayList<>(); List<Map<String, Object>> postCalendarList = new ArrayList<>();
List<PostingTask> tasks = postingTaskService.getStrategyTasks(strategyId); List<PostingTask> tasks = postingTaskService.getStrategyTasks(strategyId);
// ИСПРАВЛЕНИЕ: Выдаем postIndex явно для фронтенда
List<MarketingStrategy.PostCalendarItem> calendarItems = strategy.getPostCalendar(); List<MarketingStrategy.PostCalendarItem> calendarItems = strategy.getPostCalendar();
for (int i = 0; i < calendarItems.size(); i++) { for (int i = 0; i < calendarItems.size(); i++) {
MarketingStrategy.PostCalendarItem item = calendarItems.get(i); MarketingStrategy.PostCalendarItem item = calendarItems.get(i);
Map<String, Object> dtoItem = new HashMap<>(); Map<String, Object> dtoItem = new HashMap<>();
dtoItem.put("postIndex", i); // ЯВНЫЙ ИНДЕКС dtoItem.put("postIndex", i);
dtoItem.put("publishDate", item.getPublishDate()); dtoItem.put("publishDate", item.getPublishDate());
dtoItem.put("platform", item.getPlatform()); dtoItem.put("platform", item.getPlatform());
dtoItem.put("contentType", item.getContentType()); dtoItem.put("contentType", item.getContentType());
@@ -254,7 +251,6 @@ public class MarketingStrategyV3Service {
return item; return item;
} }
// ИСПРАВЛЕНИЕ: Новый АСИНХРОННЫЙ метод для регенерации видео без таймаутов
@Async("reportGenerationExecutor") @Async("reportGenerationExecutor")
public void regeneratePostVideoAsync(String strategyId, int postIndex) { public void regeneratePostVideoAsync(String strategyId, int postIndex) {
try { try {
@@ -275,7 +271,6 @@ public class MarketingStrategyV3Service {
MarketingStrategy.PostCalendarItem item = postCalendar.get(postIndex); MarketingStrategy.PostCalendarItem item = postCalendar.get(postIndex);
item.setContentType("видео"); item.setContentType("видео");
// Ставим заглушку для фронтенда
item.setVideoUrl("generating..."); item.setVideoUrl("generating...");
item.setVideoFilename(null); item.setVideoFilename(null);
item.setImageUrl(null); item.setImageUrl(null);
@@ -293,7 +288,6 @@ public class MarketingStrategyV3Service {
log.info("Начинаем фоновую регенерацию видео для поста {} в стратегии {}", postIndex, strategyId); log.info("Начинаем фоновую регенерацию видео для поста {} в стратегии {}", postIndex, strategyId);
// Этот метод сам скачает, сохранит в MinIO и обновит поля item
doGenerateVideo(item, businessContext); doGenerateVideo(item, businessContext);
repository.save(strategy); repository.save(strategy);
@@ -485,10 +479,12 @@ public class MarketingStrategyV3Service {
} }
} }
// === ВОТ ЗДЕСЬ ОБНОВЛЕННЫЙ ПРОМПТ ДЛЯ ВИДЕО ===
private void doGenerateVideo(MarketingStrategy.PostCalendarItem item, String businessContext) { private void doGenerateVideo(MarketingStrategy.PostCalendarItem item, String businessContext) {
String theme = item.getTheme() != null ? item.getTheme() : "Commercial content"; String theme = item.getTheme() != null ? item.getTheme() : "Commercial content";
// Изменили промпт: убрали 4K, добавили стиль TikTok/Instagram Reels, сделали его более оптимизированным
String videoPrompt = String.format( 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); businessContext, theme);
try { try {
@@ -516,9 +512,10 @@ public class MarketingStrategyV3Service {
item.setVideoUrl(null); item.setVideoUrl(null);
} }
} }
// ===============================================
private void doGenerateImage(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName, private void doGenerateImage(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName,
byte[] clientRefBytes) { byte[] clientRefBytes) {
String imagePrompt = buildImagePrompt(item, businessContext, brandName); String imagePrompt = buildImagePrompt(item, businessContext, brandName);
try { try {
byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, clientRefBytes); byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, clientRefBytes);