diff --git a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java index c6a3325..f9b1e48 100644 --- a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java @@ -25,23 +25,15 @@ import java.time.Duration; import java.util.*; /** - * Сервис генерации видео через Google Veo 3 (с fallback на Veo 2). + * Генерация видео через Google Veo 3 (Vertex AI). * - * ══ ЗВУК В VEO 3 ══ - * Veo 3 добавляет звук АВТОМАТИЧЕСКИ если в промпте описан звук. - * Параметр generateAudio пока НЕ поддерживается через Vertex AI REST API — - * он доступен только через Gemini API (gemini.google.com). - * Через Vertex AI (aiplatform.googleapis.com) звук включается описанием в промпте: - * "фоновая музыка в казахстанском стиле", "закадровый голос на русском" — работает. - * parameters.put("generateAudio", true) — вызывает 400 Bad Request, не используем. + * БЕЗ fallback — если Veo 3 недоступен, явная ошибка в лог. + * Логи: без base64 мусора, только полезная информация. * - * ══ FALLBACK ══ - * Если Veo 3 недоступен (модель ещё не в вашем проекте или 404/403) — - * автоматически пробуем Veo 2. Это решает проблему "видео не генерируются". - * - * ══ ПРОМПТЫ ══ - * enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский. - * Все промпты на русском — для казахстанского рынка. + * Если видите в логах: + * "VEO3: HTTP 404" → модель не включена в проекте, запросите доступ + * "VEO3: HTTP 403" → нет IAM прав на модель + * "VEO3: HTTP 400" → неверные параметры запроса */ @Service public class GeminiVideoGenerationService { @@ -49,9 +41,6 @@ public class GeminiVideoGenerationService { private static final Logger logger = LoggerFactory.getLogger(GeminiVideoGenerationService.class); private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json"; - // Fallback модель если Veo 3 недоступна в проекте - private static final String FALLBACK_MODEL = "veo-2.0-generate-001"; - private final WebClient webClient; private GoogleCredentials credentials; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -62,6 +51,7 @@ public class GeminiVideoGenerationService { @Value("${google.cloud.location:us-central1}") private String location; + // Только Veo 3 — без fallback @Value("${google.gemini.video.model:veo-3.0-generate-001}") private String model; @@ -90,284 +80,325 @@ public class GeminiVideoGenerationService { this.credentials = GoogleCredentials.fromStream(is) .createScoped(Collections.singletonList( "https://www.googleapis.com/auth/cloud-platform")); - logger.info("VEO: Credentials loaded. Project={}, Location={}, PrimaryModel={}", + logger.info("VEO3: ✅ Credentials loaded. Project={}, Location={}, Model={}", projectId, location, model); } } else { - logger.error("VEO: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH); + logger.error("VEO3: ❌ Credentials file '{}' NOT FOUND", CREDENTIALS_FILE_PATH); } } catch (IOException e) { - logger.error("VEO: Error reading credentials: {}", e.getMessage()); + logger.error("VEO3: ❌ Error reading credentials: {}", e.getMessage()); } } private String getValidAccessToken() { if (this.credentials == null) { - logger.error("VEO: Credentials not initialized!"); + logger.error("VEO3: ❌ Credentials not initialized"); return null; } try { this.credentials.refreshIfExpired(); String token = this.credentials.getAccessToken().getTokenValue(); if (token == null || token.isEmpty()) { - logger.error("VEO: Token is null/empty after refresh!"); + logger.error("VEO3: ❌ Token is empty after refresh"); return null; } return token; } catch (IOException e) { - logger.error("VEO: Token refresh error: {}", e.getMessage()); + logger.error("VEO3: ❌ Token refresh failed: {}", e.getMessage()); return null; } } + // ===================================================================== + // PUBLIC API + // ===================================================================== + /** - * Генерирует видео по промпту на РУССКОМ языке. + * Генерирует видео через Veo 3. * - * Промпт должен содержать описание звука для активации аудио в Veo 3: - * Например: "Звук: современная казахстанская музыка, лёгкий фон" + * Veo 3 поддерживает: + * - Звук: описывай в промпте ("background music", "ambient sounds") + * - Русский язык в промпте (enhancePrompt=false чтобы не переводил) + * - Соотношение 9:16 для вертикального видео * - * Если Veo 3 недоступен — автоматический fallback на Veo 2. + * @param prompt Промпт на русском языке + * @return Байты MP4 или null при ошибке */ public byte[] generateVideo(String prompt) { if (projectId == null || projectId.trim().isEmpty()) { - logger.error("VEO: Project ID not configured!"); + logger.error("VEO3: ❌ Project ID not configured in application.properties"); return null; } String accessToken = getValidAccessToken(); if (accessToken == null) return null; - // Сначала пробуем основную модель (Veo 3) - logger.info("VEO: Trying primary model: {}", model); - byte[] result = generateWithModel(prompt, model, accessToken); - - // Если не получилось — fallback на Veo 2 - if (result == null && !model.equals(FALLBACK_MODEL)) { - logger.warn("VEO: Primary model {} failed. Trying fallback: {}", model, FALLBACK_MODEL); - // Обновляем токен на случай если старый истёк за время первой попытки - String freshToken = getValidAccessToken(); - if (freshToken != null) { - result = generateWithModel(prompt, FALLBACK_MODEL, freshToken); - } - if (result != null) { - logger.info("VEO: Fallback model {} succeeded!", FALLBACK_MODEL); - } else { - logger.error("VEO: Both models failed. Video generation unavailable."); - } + try { + return doGenerate(prompt, accessToken); + } catch (Exception e) { + logger.error("VEO3: ❌ Unexpected error: {}", e.getMessage(), e); + return null; } - - return result; } - /** - * Генерация через конкретную модель Veo. - */ - private byte[] generateWithModel(String prompt, String modelName, String accessToken) { + // ===================================================================== + // GENERATION + // ===================================================================== + + private byte[] doGenerate(String prompt, String accessToken) throws Exception { + + // ═══ ШАГ 1: Запуск операции ═══════════════════════════════════════ + String generateEndpoint = String.format( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning", + location, projectId, location, model); + + Map instance = new HashMap<>(); + instance.put("prompt", prompt); + + Map parameters = new HashMap<>(); + parameters.put("aspectRatio", "9:16"); + parameters.put("sampleCount", 1); + // false = не переводить промпт на английский + parameters.put("enhancePrompt", false); + // Негативный промпт на английском — Veo понимает его лучше + parameters.put("negativePrompt", + "text overlay, subtitles, captions, watermark, on-screen text, " + + "blurry, low quality, bad anatomy, extra limbs, deformed face, " + + "cartoon, CGI look, 3D render, artificial, jerky motion, " + + "extra fingers, distorted proportions"); + + Map requestBody = new HashMap<>(); + requestBody.put("instances", Collections.singletonList(instance)); + requestBody.put("parameters", parameters); + + logger.info("VEO3: ▶ Starting generation"); + logger.info("VEO3: Model: {}", model); + logger.info("VEO3: Endpoint: {}", generateEndpoint); + logger.info("VEO3: Prompt: {}", + prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt); + logger.info("VEO3: Params: aspectRatio=9:16, sampleCount=1, enhancePrompt=false"); + + Map initResponse; try { - // ═══ ШАГ 1: Запуск генерации ════════════════════════════════════ - String generateEndpoint = String.format( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning", - location, projectId, location, modelName); + 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(60)); - Map instance = new HashMap<>(); - instance.put("prompt", prompt); + } catch (WebClientResponseException e) { + // Подробный лог ошибки — без лишнего мусора + logger.error("VEO3: ❌ HTTP {} on START request", e.getStatusCode().value()); + logger.error("VEO3: Body: {}", e.getResponseBodyAsString()); - Map parameters = new HashMap<>(); - parameters.put("aspectRatio", "9:16"); - parameters.put("sampleCount", 1); - // КРИТИЧНО: false — иначе Google переводит промпт на английский! - parameters.put("enhancePrompt", false); - // Негативный промпт на русском — единый язык с основным промптом - parameters.put("negativePrompt", - "размытое изображение, плохая анатомия, лишние конечности, " + - "деформированные лица, мультяшный стиль, 3D рендер, " + - "искусственный неестественный вид, субтитры на английском, " + - "водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы, " + - "дёрганые движения, низкое качество, пиксели"); - // ВАЖНО: НЕ добавляем generateAudio:true — это вызывает 400 через Vertex AI REST. - // Звук в Veo 3 активируется через описание в промпте (работает через Vertex AI). + if (e.getStatusCode().value() == 404) { + logger.error("VEO3: ПРИЧИНА: Модель '{}' недоступна в проекте '{}'", model, projectId); + logger.error("VEO3: РЕШЕНИЕ: Запросите доступ к Veo 3 на https://cloud.google.com/vertex-ai/generative-ai/docs/video/generate-videos"); + } else if (e.getStatusCode().value() == 403) { + logger.error("VEO3: ПРИЧИНА: Нет IAM прав. Сервис-аккаунт должен иметь роль 'Vertex AI User'"); + } else if (e.getStatusCode().value() == 400) { + logger.error("VEO3: ПРИЧИНА: Неверные параметры запроса. Проверьте структуру тела запроса"); + } + return null; + } - Map requestBody = new HashMap<>(); - requestBody.put("instances", Collections.singletonList(instance)); - requestBody.put("parameters", parameters); + if (initResponse == null) { + logger.error("VEO3: ❌ Null response on START request"); + return null; + } - logger.info("VEO: Starting. Model={}", modelName); - logger.info("VEO: Prompt preview: '{}'", - prompt.length() > 250 ? prompt.substring(0, 250) + "..." : prompt); + String operationName = (String) initResponse.get("name"); + if (operationName == null || operationName.isEmpty()) { + logger.error("VEO3: ❌ No 'name' field in response. Full response keys: {}", + initResponse.keySet()); + return null; + } + + logger.info("VEO3: ✅ Operation started: {}", operationName); + + // ═══ ШАГ 2: Polling ════════════════════════════════════════════════ + 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 = 120; // 30 минут (120 × 15 сек) + + while (attempts < maxAttempts) { + Thread.sleep(15_000); + attempts++; + + String currentToken = getValidAccessToken(); + if (currentToken == null) { + logger.warn("VEO3: ⚠ No token on attempt {}, skipping", attempts); + continue; + } - Map initResponse; try { - initResponse = webClient.post() - .uri(URI.create(generateEndpoint)) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + Map statusResponse = webClient.post() + .uri(URI.create(fetchEndpoint)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + currentToken) .contentType(MediaType.APPLICATION_JSON) - .bodyValue(requestBody) + .bodyValue(fetchBody) .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) .block(Duration.ofSeconds(60)); - } catch (WebClientResponseException e) { - logger.error("VEO: HTTP {} on initial request (model={}). Body: {}", - e.getStatusCode(), modelName, e.getResponseBodyAsString()); - // 404 или 403 — модель недоступна в проекте, возвращаем null для fallback - return null; - } - if (initResponse == null) { - logger.error("VEO: Null initial response for model={}", modelName); - return null; - } - - String operationName = (String) initResponse.get("name"); - if (operationName == null || operationName.isEmpty()) { - logger.error("VEO: No 'name' in response! model={} response={}", - modelName, objectMapper.writeValueAsString(initResponse)); - return null; - } - logger.info("VEO: Operation started: {} (model={})", operationName, modelName); - - // ═══ ШАГ 2: Polling ══════════════════════════════════════════════ - String fetchEndpoint = String.format( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", - location, projectId, location, modelName); - - Map fetchBody = new HashMap<>(); - fetchBody.put("operationName", operationName); - - int attempts = 0; - int maxAttempts = 120; // 30 минут - - while (attempts < maxAttempts) { - Thread.sleep(15_000); - attempts++; - - String currentToken = getValidAccessToken(); - if (currentToken == null) { - logger.warn("VEO: No token on attempt {}", attempts); + if (statusResponse == null) { + logger.warn("VEO3: ⚠ Null status on attempt {}/{}", attempts, maxAttempts); continue; } - try { - 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) { - logger.warn("VEO: Null status on attempt {}", attempts); - continue; - } - - String rawJson = objectMapper.writeValueAsString(statusResponse); - logger.info("VEO: [Attempt {}/{}] model={} raw={}", - attempts, maxAttempts, modelName, rawJson); - - if (statusResponse.containsKey("error")) { - logger.error("VEO: Error in response: {}", - objectMapper.writeValueAsString(statusResponse.get("error"))); - return null; - } - - Object doneObj = statusResponse.get("done"); - boolean isDone = Boolean.TRUE.equals(doneObj) - || "true".equalsIgnoreCase(String.valueOf(doneObj)); - - if (!isDone) { - logger.info("VEO: Still processing {}/{}", attempts, maxAttempts); - continue; - } - - logger.info("VEO: DONE on attempt {}! Extracting video...", attempts); - - // ═══ ШАГ 3: Извлечение видео ═════════════════════════════ + // Проверяем ошибку + if (statusResponse.containsKey("error")) { @SuppressWarnings("unchecked") - Map responseObj = - (Map) statusResponse.get("response"); - if (responseObj == null) { - logger.warn("VEO: No 'response' field — trying root..."); - responseObj = statusResponse; - } - - // Вариант 1 — base64 - String b64 = findFirstValue(responseObj, "bytesBase64Encoded"); - if (b64 != null && !b64.isEmpty()) { - logger.info("VEO: Found base64 (len={})", b64.length()); - return decodeBase64(b64); - } - - // Вариант 2 — GCS URI - String videoUri = findFirstValue(responseObj, "videoUri"); - if (videoUri == null) videoUri = findFirstValue(responseObj, "uri"); - if (videoUri != null && !videoUri.isEmpty()) { - logger.info("VEO: Found URI: {}", videoUri); - return downloadFromGcs(videoUri, currentToken); - } - - // Вариант 3 — predictions напрямую - @SuppressWarnings("unchecked") - List> predictions = - (List>) responseObj.get("predictions"); - if (predictions != null && !predictions.isEmpty()) { - Map first = predictions.get(0); - logger.info("VEO: Prediction keys: {}", first.keySet()); - - b64 = (String) first.get("bytesBase64Encoded"); - if (b64 != null && !b64.isEmpty()) return decodeBase64(b64); - - String uri = (String) first.get("videoUri"); - if (uri == null) uri = (String) first.get("uri"); - if (uri != null && !uri.isEmpty()) return downloadFromGcs(uri, currentToken); - } - - logger.error("VEO: Video not found! Full: {}", rawJson); + Map error = (Map) statusResponse.get("error"); + logger.error("VEO3: ❌ Operation failed with error:"); + logger.error("VEO3: code={}, message={}", + error.get("code"), error.get("message")); return null; - - } catch (WebClientResponseException e) { - logger.error("VEO: HTTP {} on attempt {}: {}", - e.getStatusCode(), attempts, e.getResponseBodyAsString()); - if (e.getStatusCode().is4xxClientError()) { - logger.error("VEO: 4xx — stopping poll for model={}", modelName); - return null; - } - // 5xx — продолжаем } + + // Проверяем done + Object doneObj = statusResponse.get("done"); + boolean isDone = Boolean.TRUE.equals(doneObj) + || "true".equalsIgnoreCase(String.valueOf(doneObj)); + + if (!isDone) { + // Показываем прогресс каждые 5 попыток + if (attempts % 5 == 0 || attempts <= 3) { + logger.info("VEO3: ⏳ Still processing... attempt {}/{} (~{}min elapsed)", + attempts, maxAttempts, (attempts * 15 / 60)); + } + continue; + } + + logger.info("VEO3: ✅ DONE on attempt {} (~{}min)", attempts, (attempts * 15 / 60)); + + // ═══ ШАГ 3: Извлечение видео ═════════════════════════════ + return extractVideo(statusResponse); + + } catch (WebClientResponseException e) { + logger.error("VEO3: ❌ HTTP {} on attempt {}: {}", + e.getStatusCode().value(), attempts, e.getResponseBodyAsString()); + if (e.getStatusCode().is4xxClientError()) { + logger.error("VEO3: 4xx error — stopping poll"); + return null; + } + // 5xx — продолжаем + logger.warn("VEO3: 5xx error — will retry"); + } + } + + logger.error("VEO3: ❌ TIMEOUT after {} minutes", (maxAttempts * 15 / 60)); + return null; + } + + // ===================================================================== + // VIDEO EXTRACTION — без логирования base64 + // ===================================================================== + + private byte[] extractVideo(Map statusResponse) { + @SuppressWarnings("unchecked") + Map responseObj = (Map) statusResponse.get("response"); + if (responseObj == null) { + logger.warn("VEO3: No 'response' field, trying root object"); + responseObj = statusResponse; + } + + // Вариант 1 — base64 (НЕ логируем значение — это мусор в логах) + String b64 = findFirstValue(responseObj, "bytesBase64Encoded"); + if (b64 != null && !b64.isEmpty()) { + logger.info("VEO3: Found base64 encoded video (length={}chars)", b64.length()); + byte[] decoded = decodeBase64(b64); + if (decoded != null) { + logger.info("VEO3: ✅ Decoded video: {} bytes ({} MB)", + decoded.length, String.format("%.1f", decoded.length / 1024.0 / 1024.0)); + } + return decoded; + } + + // Вариант 2 — GCS URI + String videoUri = findFirstValue(responseObj, "videoUri"); + if (videoUri == null) videoUri = findFirstValue(responseObj, "uri"); + if (videoUri != null && !videoUri.isEmpty()) { + logger.info("VEO3: Found video URI: {}", videoUri); + return downloadFromGcs(videoUri, getValidAccessToken()); + } + + // Вариант 3 — predictions + @SuppressWarnings("unchecked") + List> predictions = + (List>) responseObj.get("predictions"); + if (predictions != null && !predictions.isEmpty()) { + Map first = predictions.get(0); + logger.info("VEO3: Checking predictions[0]. Keys: {}", first.keySet()); + + b64 = (String) first.get("bytesBase64Encoded"); + if (b64 != null && !b64.isEmpty()) { + logger.info("VEO3: Found base64 in predictions (length={}chars)", b64.length()); + byte[] decoded = decodeBase64(b64); + if (decoded != null) + logger.info("VEO3: ✅ Decoded: {} bytes ({} MB)", + decoded.length, String.format("%.1f", decoded.length / 1024.0 / 1024.0)); + return decoded; } - logger.error("VEO: TIMEOUT after {} min for model={}", (maxAttempts * 15 / 60), modelName); - return null; - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.error("VEO: Interrupted"); - return null; - } catch (Exception e) { - logger.error("VEO: Fatal error (model={}): {}", modelName, e.getMessage(), e); - return null; + String uri = (String) first.get("videoUri"); + if (uri == null) uri = (String) first.get("uri"); + if (uri != null && !uri.isEmpty()) { + logger.info("VEO3: Found URI in predictions: {}", uri); + return downloadFromGcs(uri, getValidAccessToken()); + } } + + // Ничего не нашли — логируем только ключи (не значения!) + logger.error("VEO3: ❌ Video not found in response!"); + logger.error("VEO3: statusResponse keys: {}", statusResponse.keySet()); + if (responseObj != statusResponse) { + logger.error("VEO3: responseObj keys: {}", responseObj.keySet()); + } + if (predictions != null && !predictions.isEmpty()) { + logger.error("VEO3: predictions[0] keys: {}", predictions.get(0).keySet()); + } + return null; } + // ===================================================================== + // HELPERS + // ===================================================================== + private byte[] decodeBase64(String b64) { try { String clean = b64.replaceAll("\\s", ""); - if (clean.contains("base64,")) clean = clean.substring(clean.indexOf("base64,") + 7); - byte[] bytes = Base64.getDecoder().decode(clean); - logger.info("VEO: Decoded {} bytes", bytes.length); - return bytes; + if (clean.contains("base64,")) { + clean = clean.substring(clean.indexOf("base64,") + 7); + } + return Base64.getDecoder().decode(clean); } catch (Exception e) { - logger.error("VEO: base64 decode failed: {}", e.getMessage()); + logger.error("VEO3: ❌ base64 decode failed: {}", e.getMessage()); return null; } } private byte[] downloadFromGcs(String uri, String accessToken) { + if (accessToken == null) { + logger.error("VEO3: ❌ Cannot download GCS — no access token"); + return null; + } try { String downloadUrl = uri.startsWith("gs://") ? "https://storage.googleapis.com/" + uri.substring(5) : uri; - logger.info("VEO: Downloading from: {}", downloadUrl); + + logger.info("VEO3: Downloading from GCS: {}", downloadUrl); byte[] bytes = webClient.get() .uri(URI.create(downloadUrl)) @@ -377,13 +408,15 @@ public class GeminiVideoGenerationService { .block(Duration.ofMinutes(5)); if (bytes != null && bytes.length > 0) { - logger.info("VEO: Downloaded {} bytes from GCS", bytes.length); + logger.info("VEO3: ✅ Downloaded {} bytes ({} MB) from GCS", + bytes.length, String.format("%.1f", bytes.length / 1024.0 / 1024.0)); return bytes; } - logger.error("VEO: Empty download from: {}", uri); + logger.error("VEO3: ❌ Empty download from GCS: {}", uri); return null; + } catch (Exception e) { - logger.error("VEO: GCS download error {}: {}", uri, e.getMessage()); + logger.error("VEO3: ❌ GCS download error: {}", e.getMessage()); return null; } } diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java index a159440..e46556a 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java @@ -686,69 +686,94 @@ public class MarketingStrategyV3Service { */ private boolean doGenerateVideo(MarketingStrategy.PostCalendarItem item, String niche, String brand, String city) { + String theme = item.getTheme() != null ? item.getTheme() : "Презентация продукта"; String postText = item.getPostText() != null ? item.getPostText() : ""; - String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram"; + String platform = item.getPlatform() != null + ? item.getPlatform().toLowerCase() : "instagram"; - // Извлекаем суть поста для визуализации (первые 100 символов текста) - String postSummary = postText.length() > 100 - ? postText.substring(0, 100) : postText; + // Берём суть из текста поста — первые 120 символов + String postContext = postText.length() > 120 + ? postText.substring(0, 120).trim() : postText.trim(); - // Стиль по платформе - String styleDesc, audioDesc; + // ── Стиль по платформе (на английском — Veo 3 лучше понимает стиль на EN) ── + String styleEn; + String audioEn; switch (platform) { case "tiktok" -> { - styleDesc = "динамичный TikTok-стиль, быстрый монтаж, трендовые переходы, энергичная подача"; - audioDesc = "энергичная трендовая музыка для казахстанской молодёжи"; + styleEn = "dynamic TikTok Reels style, fast cuts, trendy transitions, " + + "energetic handheld camera, vertical 9:16"; + audioEn = "upbeat modern Kazakh music, energetic rhythm, no vocals"; } case "telegram" -> { - styleDesc = "лаконичный информативный стиль, профессиональный вид"; - audioDesc = "спокойная фоновая музыка или закадровый голос на русском"; + styleEn = "clean informative style, steady camera, professional look, vertical 9:16"; + audioEn = "soft background music, calm ambient, subtle"; } default -> { // instagram - styleDesc = "кинематографичный Instagram Reels стиль, плавные движения камеры, эстетичная картинка"; - audioDesc = "современная казахстанская музыка или атмосферный звук"; + styleEn = "cinematic Instagram Reels style, smooth camera movement, " + + "aesthetic lifestyle feel, warm color grading, vertical 9:16"; + audioEn = "modern ambient music, soft background, atmospheric, no lyrics"; } } - // КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: промпт строится из конкретной темы поста + // ── Финальный промпт: русский контекст + английские технические команды ── + // Структура: [ЧТО ПОКАЗАТЬ на русском] + [КАК СНЯТЬ на английском] + // Это даёт Veo 3 лучший результат — понимает контент И технические требования String videoPrompt = String.format( - "Кинематографичное вертикальное видео 9:16 для %s в Казахстане. " + - "Ниша: %s. Бренд: %s. Город: %s. " + - "ТЕМА ВИДЕО: «%s». " + - "ЧТО ПОКАЗАТЬ: %s. " + - "Стиль: %s. " + - "Звук: %s. " + - "Требования: фотореалистичное 8K, профессиональное освещение, " + - "правильная анатомия, естественные движения и эмоции, " + - "казахстанский контекст — современный город, стильные люди, реальная жизнь. " + - "БЕЗ текстовых оверлеев, БЕЗ водяных знаков, БЕЗ субтитров.", - platform, niche, brand, city, theme, postSummary, styleDesc, audioDesc); + // Часть 1: ЧТО показывать (контекст ниши — на русском) + "Видео для бизнеса в нише «%s», бренд «%s», город %s. " + + "Тема: «%s». " + + "Показать: %s. " + + // Часть 2: КАК снимать (технические требования — на английском для точности) + "Cinematic photorealistic 8K vertical video. " + + "Style: %s. " + + // ЗВУК — явное указание для Veo 3 + "Audio: %s. " + + // КРИТИЧНО: запрет текста в кадре + "IMPORTANT: NO text on screen, NO subtitles, NO captions, NO watermarks, NO logos. " + + // Требования к людям и качеству + "People: natural Kazakh appearance, authentic emotions, realistic proportions. " + + "Quality: sharp focus, professional lighting, no blur, no CGI look.", + niche, brand, city, + theme, + postContext.isEmpty() ? theme : postContext, + styleEn, + audioEn + ); - log.info("[VEO3] Generating video. Platform={}, Theme='{}'", platform, theme); - log.debug("[VEO3] Prompt: {}", videoPrompt); + // Сохраняем промпт для отладки + item.setVideoGenerationPrompt(videoPrompt); + + log.info("[VEO3] Generating video:"); + log.info("[VEO3] Platform = {}", platform); + log.info("[VEO3] Theme = {}", theme); + log.info("[VEO3] Niche = {} | Brand = {} | City = {}", niche, brand, city); + log.info("[VEO3] Prompt = {}", + videoPrompt.length() > 400 ? videoPrompt.substring(0, 400) + "..." : videoPrompt); try { byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt); + if (videoBytes != null && videoBytes.length > 0) { String filename = "video_" + System.currentTimeMillis() + "_" + Math.abs(item.hashCode()) + ".mp4"; minIOService.uploadFile(filename, videoBytes, "video/mp4"); item.setVideoUrl(filename); item.setVideoFilename(filename); - item.setVideoGenerationPrompt(videoPrompt); // сохраняем промпт для отладки item.setImageUrl(null); item.setImageFilename(null); - log.info("[VEO3] ✅ Saved: {} ({} bytes)", filename, videoBytes.length); + log.info("[VEO3] ✅ Saved: {} ({} MB)", + filename, String.format("%.1f", videoBytes.length / 1024.0 / 1024.0)); return true; } else { - log.warn("[VEO3] Empty response for theme '{}'", theme); + log.error("[VEO3] ❌ Empty response for theme '{}'", theme); + log.error("[VEO3] Проверьте логи GeminiVideoGenerationService для причины ошибки"); item.setVideoFilename(null); item.setVideoUrl(null); return false; } } catch (Exception e) { - log.error("[VEO3] Error for '{}': {}", theme, e.getMessage()); + log.error("[VEO3] ❌ Exception for theme '{}': {}", theme, e.getMessage(), e); item.setVideoFilename(null); item.setVideoUrl(null); return false;