From f5f449c4f78c0d069237dd39b006261357c18569 Mon Sep 17 00:00:00 2001 From: arys Date: Thu, 5 Mar 2026 03:43:13 +0500 Subject: [PATCH] fix --- .../service/GeminiVideoGenerationService.java | 305 ++++++++++++++---- .../service/MarketingStrategyV3Service.java | 19 +- 2 files changed, 246 insertions(+), 78 deletions(-) diff --git a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java index 42226d4..0430678 100644 --- a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java @@ -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 instance = new HashMap<>(); + instance.put("prompt", prompt); - // Оставляем ТОЛЬКО формат видео. Лишние параметры крашат Google API. Map parameters = new HashMap<>(); parameters.put("aspectRatio", "9:16"); + parameters.put("sampleCount", 1); + // Можно добавить: parameters.put("durationSeconds", 8); Map 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 initResponse = webClient.post() - .uri(URI.create(generateEndpoint)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(requestBody) - .retrieve() - .bodyToMono(new ParameterizedTypeReference>() {}) - .block(Duration.ofSeconds(120)); - - if (initResponse == null || !initResponse.containsKey("name")) { - logger.error("VEO: Ошибка: Google не вернул имя операции."); + Map initResponse; + try { + initResponse = webClient.post() + .uri(URI.create(generateEndpoint)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .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 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 statusResponse = webClient.get() + Map statusResponse = webClient.post() .uri(URI.create(fetchEndpoint)) .header(HttpHeaders.AUTHORIZATION, "Bearer " + currentToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(fetchBody) .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) .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 responseObj = (Map) 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 responseObj = (Map) 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> predictions = (List>) responseObj.get("predictions"); + if (predictions != null && !predictions.isEmpty()) { + Map 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 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 map = (Map) 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); diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java index dde130e..c0ed0f1 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java @@ -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 referenceFilenames) { + List 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> postCalendarList = new ArrayList<>(); List tasks = postingTaskService.getStrategyTasks(strategyId); - // ИСПРАВЛЕНИЕ: Выдаем postIndex явно для фронтенда List calendarItems = strategy.getPostCalendar(); for (int i = 0; i < calendarItems.size(); i++) { MarketingStrategy.PostCalendarItem item = calendarItems.get(i); Map 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);