This commit is contained in:
arys
2026-03-26 10:22:19 +05:00
parent 49ee427688
commit 1fa126286c
4 changed files with 748 additions and 472 deletions
@@ -614,7 +614,7 @@ public class MarketingAnalysisV3Controller {
if (title.length() > 80) { if (title.length() > 80) {
title = title.substring(0, 77) + "..."; title = title.substring(0, 77) + "...";
} }
response.setAnalysisTitle(title); response.setAnalysisTitle(title);
} }
}); });
@@ -25,12 +25,23 @@ import java.time.Duration;
import java.util.*; import java.util.*;
/** /**
* Сервис генерации видео через Google Veo 3. * Сервис генерации видео через Google Veo 3 (с fallback на Veo 2).
* *
* КЛЮЧЕВЫЕ ОСОБЕННОСТИ Veo 3: * ══ ЗВУК В VEO 3 ══
* - Нативный звук: музыка, диалоги, фоновые звуки — без доп. настроек * Veo 3 добавляет звук АВТОМАТИЧЕСКИ если в промпте описан звук.
* - enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский * Параметр 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 недоступен (модель ещё не в вашем проекте или 404/403) —
* автоматически пробуем Veo 2. Это решает проблему "видео не генерируются".
*
* ══ ПРОМПТЫ ══
* enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский.
* Все промпты на русском — для казахстанского рынка.
*/ */
@Service @Service
public class GeminiVideoGenerationService { public class GeminiVideoGenerationService {
@@ -38,6 +49,9 @@ public class GeminiVideoGenerationService {
private static final Logger logger = LoggerFactory.getLogger(GeminiVideoGenerationService.class); private static final Logger logger = LoggerFactory.getLogger(GeminiVideoGenerationService.class);
private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json"; 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 final WebClient webClient;
private GoogleCredentials credentials; private GoogleCredentials credentials;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@@ -76,32 +90,32 @@ public class GeminiVideoGenerationService {
this.credentials = GoogleCredentials.fromStream(is) this.credentials = GoogleCredentials.fromStream(is)
.createScoped(Collections.singletonList( .createScoped(Collections.singletonList(
"https://www.googleapis.com/auth/cloud-platform")); "https://www.googleapis.com/auth/cloud-platform"));
logger.info("VEO3: Credentials loaded. Project={}, Location={}, Model={}", logger.info("VEO: Credentials loaded. Project={}, Location={}, PrimaryModel={}",
projectId, location, model); projectId, location, model);
} }
} else { } else {
logger.error("VEO3: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH); logger.error("VEO: CRITICAL — credentials file '{}' not found!", CREDENTIALS_FILE_PATH);
} }
} catch (IOException e) { } catch (IOException e) {
logger.error("VEO3: Error reading credentials: {}", e.getMessage()); logger.error("VEO: Error reading credentials: {}", e.getMessage());
} }
} }
private String getValidAccessToken() { private String getValidAccessToken() {
if (this.credentials == null) { if (this.credentials == null) {
logger.error("VEO3: Credentials not initialized!"); logger.error("VEO: Credentials not initialized!");
return null; return null;
} }
try { try {
this.credentials.refreshIfExpired(); this.credentials.refreshIfExpired();
String token = this.credentials.getAccessToken().getTokenValue(); String token = this.credentials.getAccessToken().getTokenValue();
if (token == null || token.isEmpty()) { if (token == null || token.isEmpty()) {
logger.error("VEO3: Access token is null/empty after refresh!"); logger.error("VEO: Token is null/empty after refresh!");
return null; return null;
} }
return token; return token;
} catch (IOException e) { } catch (IOException e) {
logger.error("VEO3: Token refresh error: {}", e.getMessage()); logger.error("VEO: Token refresh error: {}", e.getMessage());
return null; return null;
} }
} }
@@ -109,24 +123,51 @@ public class GeminiVideoGenerationService {
/** /**
* Генерирует видео по промпту на РУССКОМ языке. * Генерирует видео по промпту на РУССКОМ языке.
* *
* Промпт формируется в MarketingStrategyV3Service.doGenerateVideo() * Промпт должен содержать описание звука для активации аудио в Veo 3:
* с учётом казахстанского контекста, города, платформы и темы поста. * Например: "Звук: современная казахстанская музыка, лёгкий фон"
* Veo 3 автоматически добавляет звук (музыку/диалоги) к видео. *
* Если Veo 3 недоступен — автоматический fallback на Veo 2.
*/ */
public byte[] generateVideo(String prompt) { public byte[] generateVideo(String prompt) {
if (projectId == null || projectId.trim().isEmpty()) { if (projectId == null || projectId.trim().isEmpty()) {
logger.error("VEO3: Project ID is not configured!"); logger.error("VEO: Project ID not configured!");
return null; return null;
} }
String accessToken = getValidAccessToken(); String accessToken = getValidAccessToken();
if (accessToken == null) return null; 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.");
}
}
return result;
}
/**
* Генерация через конкретную модель Veo.
*/
private byte[] generateWithModel(String prompt, String modelName, String accessToken) {
try { try {
// ═══ ШАГ 1: Запуск генерации через predictLongRunning ════════════ // ═══ ШАГ 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",
location, projectId, location, model); location, projectId, location, modelName);
Map<String, Object> instance = new HashMap<>(); Map<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt); instance.put("prompt", prompt);
@@ -134,21 +175,25 @@ public class GeminiVideoGenerationService {
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("sampleCount", 1);
// КРИТИЧНО: false — не давать Google переводить промпт на английский // КРИТИЧНО: false — иначе Google переводит промпт на английский!
parameters.put("enhancePrompt", false); parameters.put("enhancePrompt", false);
// Негативный промпт тоже на русском // Негативный промпт на русском — единый язык с основным промптом
parameters.put("negativePrompt", parameters.put("negativePrompt",
"размытое изображение, плохая анатомия, лишние конечности, деформированные лица, " + "размытое изображение, плохая анатомия, лишние конечности, " +
"мультяшный стиль, 3D рендер, искусственный вид, субтитры на английском, " + "деформированные лица, мультяшный стиль, 3D рендер, " +
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы"); "искусственный неестественный вид, субтитры на английском, " +
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы, " +
"дёрганые движения, низкое качество, пиксели");
// ВАЖНО: НЕ добавляем generateAudio:true — это вызывает 400 через Vertex AI REST.
// Звук в Veo 3 активируется через описание в промпте (работает через Vertex AI).
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("instances", Collections.singletonList(instance)); requestBody.put("instances", Collections.singletonList(instance));
requestBody.put("parameters", parameters); requestBody.put("parameters", parameters);
logger.info("VEO3: Starting generation. Model={}, Endpoint={}", model, generateEndpoint); logger.info("VEO: Starting. Model={}", modelName);
logger.info("VEO3: Prompt (preview 300): '{}'", logger.info("VEO: Prompt preview: '{}'",
prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt); prompt.length() > 250 ? prompt.substring(0, 250) + "..." : prompt);
Map<String, Object> initResponse; Map<String, Object> initResponse;
try { try {
@@ -159,46 +204,45 @@ public class GeminiVideoGenerationService {
.bodyValue(requestBody) .bodyValue(requestBody)
.retrieve() .retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {}) .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(120)); .block(Duration.ofSeconds(60));
} catch (WebClientResponseException e) { } catch (WebClientResponseException e) {
logger.error("VEO3: HTTP {} on initial request. Body: {}", logger.error("VEO: HTTP {} on initial request (model={}). Body: {}",
e.getStatusCode(), e.getResponseBodyAsString()); e.getStatusCode(), modelName, e.getResponseBodyAsString());
// 404 или 403 — модель недоступна в проекте, возвращаем null для fallback
return null; return null;
} }
if (initResponse == null) { if (initResponse == null) {
logger.error("VEO3: Initial response is null!"); logger.error("VEO: Null initial response for model={}", modelName);
return null; return null;
} }
logger.info("VEO3: Initial response: {}", objectMapper.writeValueAsString(initResponse));
String operationName = (String) initResponse.get("name"); String operationName = (String) initResponse.get("name");
if (operationName == null || operationName.isEmpty()) { if (operationName == null || operationName.isEmpty()) {
logger.error("VEO3: No 'name' field! Full response: {}", logger.error("VEO: No 'name' in response! model={} response={}",
objectMapper.writeValueAsString(initResponse)); modelName, objectMapper.writeValueAsString(initResponse));
return null; return null;
} }
logger.info("VEO3: Operation started: {}", operationName); logger.info("VEO: Operation started: {} (model={})", operationName, modelName);
// ═══ ШАГ 2: Polling через fetchPredictOperation ══════════════════ // ═══ ШАГ 2: Polling ══════════════════════════════════════════════
String fetchEndpoint = String.format( String fetchEndpoint = String.format(
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation",
location, projectId, location, model); location, projectId, location, modelName);
Map<String, Object> fetchBody = new HashMap<>(); Map<String, Object> fetchBody = new HashMap<>();
fetchBody.put("operationName", operationName); fetchBody.put("operationName", operationName);
int attempts = 0; int attempts = 0;
int maxAttempts = 120; // 30 минут (120 × 15 сек) int maxAttempts = 120; // 30 минут
while (attempts < maxAttempts) { while (attempts < maxAttempts) {
Thread.sleep(15000); Thread.sleep(15_000);
attempts++; attempts++;
String currentToken = getValidAccessToken(); String currentToken = getValidAccessToken();
if (currentToken == null) { if (currentToken == null) {
logger.warn("VEO3: No token on attempt {}, skipping...", attempts); logger.warn("VEO: No token on attempt {}", attempts);
continue; continue;
} }
@@ -213,15 +257,16 @@ public class GeminiVideoGenerationService {
.block(Duration.ofSeconds(60)); .block(Duration.ofSeconds(60));
if (statusResponse == null) { if (statusResponse == null) {
logger.warn("VEO3: Null status on attempt {}", attempts); logger.warn("VEO: Null status on attempt {}", attempts);
continue; continue;
} }
String rawJson = objectMapper.writeValueAsString(statusResponse); String rawJson = objectMapper.writeValueAsString(statusResponse);
logger.info("VEO3: [Attempt {}/{}] Raw: {}", attempts, maxAttempts, rawJson); logger.info("VEO: [Attempt {}/{}] model={} raw={}",
attempts, maxAttempts, modelName, rawJson);
if (statusResponse.containsKey("error")) { if (statusResponse.containsKey("error")) {
logger.error("VEO3: Google returned error: {}", logger.error("VEO: Error in response: {}",
objectMapper.writeValueAsString(statusResponse.get("error"))); objectMapper.writeValueAsString(statusResponse.get("error")));
return null; return null;
} }
@@ -231,33 +276,33 @@ public class GeminiVideoGenerationService {
|| "true".equalsIgnoreCase(String.valueOf(doneObj)); || "true".equalsIgnoreCase(String.valueOf(doneObj));
if (!isDone) { if (!isDone) {
logger.info("VEO3: Still processing... {}/{}", attempts, maxAttempts); logger.info("VEO: Still processing {}/{}", attempts, maxAttempts);
continue; continue;
} }
logger.info("VEO3: DONE on attempt {}! Extracting...", attempts); logger.info("VEO: DONE on attempt {}! Extracting video...", attempts);
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════ // ═══ ШАГ 3: Извлечение видео ═════════════════════════════
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> responseObj = Map<String, Object> responseObj =
(Map<String, Object>) statusResponse.get("response"); (Map<String, Object>) statusResponse.get("response");
if (responseObj == null) { if (responseObj == null) {
logger.warn("VEO3: No 'response' field, trying root..."); logger.warn("VEO: No 'response' field trying root...");
responseObj = statusResponse; responseObj = statusResponse;
} }
// Вариант 1 — base64 // Вариант 1 — base64
String b64 = findFirstValue(responseObj, "bytesBase64Encoded"); String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) { if (b64 != null && !b64.isEmpty()) {
logger.info("VEO3: Found base64 (length={})", b64.length()); logger.info("VEO: Found base64 (len={})", b64.length());
return decodeBase64Video(b64); return decodeBase64(b64);
} }
// Вариант 2 — GCS URI // Вариант 2 — GCS URI
String videoUri = findFirstValue(responseObj, "videoUri"); String videoUri = findFirstValue(responseObj, "videoUri");
if (videoUri == null) videoUri = findFirstValue(responseObj, "uri"); if (videoUri == null) videoUri = findFirstValue(responseObj, "uri");
if (videoUri != null && !videoUri.isEmpty()) { if (videoUri != null && !videoUri.isEmpty()) {
logger.info("VEO3: Found video URI: {}", videoUri); logger.info("VEO: Found URI: {}", videoUri);
return downloadFromGcs(videoUri, currentToken); return downloadFromGcs(videoUri, currentToken);
} }
@@ -267,87 +312,78 @@ public class GeminiVideoGenerationService {
(List<Map<String, Object>>) responseObj.get("predictions"); (List<Map<String, Object>>) responseObj.get("predictions");
if (predictions != null && !predictions.isEmpty()) { if (predictions != null && !predictions.isEmpty()) {
Map<String, Object> first = predictions.get(0); Map<String, Object> first = predictions.get(0);
logger.info("VEO3: Prediction keys: {}", first.keySet()); logger.info("VEO: Prediction keys: {}", first.keySet());
b64 = (String) first.get("bytesBase64Encoded"); b64 = (String) first.get("bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) return decodeBase64Video(b64); if (b64 != null && !b64.isEmpty()) return decodeBase64(b64);
String uri = (String) first.get("videoUri"); String uri = (String) first.get("videoUri");
if (uri == null) uri = (String) first.get("uri"); if (uri == null) uri = (String) first.get("uri");
if (uri != null && !uri.isEmpty()) return downloadFromGcs(uri, currentToken); if (uri != null && !uri.isEmpty()) return downloadFromGcs(uri, currentToken);
} }
logger.error("VEO3: Video not found in response! Full dump: {}", rawJson); logger.error("VEO: Video not found! Full: {}", rawJson);
return null; return null;
} catch (WebClientResponseException e) { } catch (WebClientResponseException e) {
logger.error("VEO3: HTTP error on attempt {}: {} — {}", logger.error("VEO: HTTP {} on attempt {}: {}",
attempts, e.getStatusCode(), e.getResponseBodyAsString()); e.getStatusCode(), attempts, e.getResponseBodyAsString());
if (e.getStatusCode().is4xxClientError()) { if (e.getStatusCode().is4xxClientError()) {
logger.error("VEO3: 4xx — stopping poll."); logger.error("VEO: 4xx — stopping poll for model={}", modelName);
return null; return null;
} }
// При 5xx продолжаем // 5xx продолжаем
} }
} }
logger.error("VEO3: TIMEOUT after {} minutes.", (maxAttempts * 15 / 60)); logger.error("VEO: TIMEOUT after {} min for model={}", (maxAttempts * 15 / 60), modelName);
return null; return null;
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
logger.error("VEO3: Thread interrupted"); logger.error("VEO: Interrupted");
return null; return null;
} catch (Exception e) { } catch (Exception e) {
logger.error("VEO3: Fatal error: {}", e.getMessage(), e); logger.error("VEO: Fatal error (model={}): {}", modelName, e.getMessage(), e);
return null; return null;
} }
} }
private byte[] decodeBase64Video(String b64) { private byte[] decodeBase64(String b64) {
try { try {
String clean = b64.replaceAll("\\s", ""); String clean = b64.replaceAll("\\s", "");
if (clean.contains("base64,")) { if (clean.contains("base64,")) clean = clean.substring(clean.indexOf("base64,") + 7);
clean = clean.substring(clean.indexOf("base64,") + 7);
}
byte[] bytes = Base64.getDecoder().decode(clean); byte[] bytes = Base64.getDecoder().decode(clean);
logger.info("VEO3: Decoded {} bytes from base64", bytes.length); logger.info("VEO: Decoded {} bytes", bytes.length);
return bytes; return bytes;
} catch (Exception e) { } catch (Exception e) {
logger.error("VEO3: Failed to decode base64: {}", e.getMessage()); logger.error("VEO: base64 decode failed: {}", e.getMessage());
return null; return null;
} }
} }
private byte[] downloadFromGcs(String uri, String accessToken) { private byte[] downloadFromGcs(String uri, String accessToken) {
try { try {
String downloadUrl; String downloadUrl = uri.startsWith("gs://")
if (uri.startsWith("gs://")) { ? "https://storage.googleapis.com/" + uri.substring(5)
downloadUrl = "https://storage.googleapis.com/" + uri.substring(5); : uri;
logger.info("VEO3: GCS → HTTPS: {}", downloadUrl); logger.info("VEO: Downloading from: {}", downloadUrl);
} else if (uri.startsWith("https://")) {
downloadUrl = uri;
} else {
logger.error("VEO3: Unknown URI format: {}", uri);
return null;
}
byte[] videoBytes = webClient.get() byte[] bytes = webClient.get()
.uri(URI.create(downloadUrl)) .uri(URI.create(downloadUrl))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.retrieve() .retrieve()
.bodyToMono(byte[].class) .bodyToMono(byte[].class)
.block(Duration.ofMinutes(5)); .block(Duration.ofMinutes(5));
if (videoBytes != null && videoBytes.length > 0) { if (bytes != null && bytes.length > 0) {
logger.info("VEO3: Downloaded {} bytes from GCS", videoBytes.length); logger.info("VEO: Downloaded {} bytes from GCS", bytes.length);
return videoBytes; return bytes;
} else {
logger.error("VEO3: Empty download from: {}", uri);
return null;
} }
logger.error("VEO: Empty download from: {}", uri);
return null;
} catch (Exception e) { } catch (Exception e) {
logger.error("VEO3: Download error from {}: {}", uri, e.getMessage()); logger.error("VEO: GCS download error {}: {}", uri, e.getMessage());
return null; return null;
} }
} }
File diff suppressed because it is too large Load Diff
@@ -23,15 +23,38 @@ import java.time.Duration;
import java.util.*; import java.util.*;
import java.util.concurrent.Semaphore; import java.util.concurrent.Semaphore;
/**
* Сервис генерации изображений через Google Imagen 3.
*
* УЛУЧШЕНИЯ:
* - Соотношение сторон зависит от платформы (4:5 для Instagram, 9:16 для TikTok/Reels)
* - Негативный промпт на русском — единый язык с основным промптом
* - Референс-изображение не перебивает основной промпт английскими инструкциями
* - Метод generateImageForPlatform() для точного контроля формата
*/
@Service @Service
public class NanoBananaImageGenerationService implements ImageGenerationService { public class NanoBananaImageGenerationService implements ImageGenerationService {
private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class); private static final Logger logger = LoggerFactory.getLogger(NanoBananaImageGenerationService.class);
private static final String VERTEX_API_TEMPLATE = "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predict";
private static final String VERTEX_API_TEMPLATE =
"https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predict";
private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json"; private static final String CREDENTIALS_FILE_PATH = "keys/google-key.json";
// Негативный промпт на русском — соответствует языку основных промптов
private static final String NEGATIVE_PROMPT_RU =
"размытое изображение, плохая анатомия, лишние конечности, шесть пальцев, три руки, " +
"деформированное тело, неправильные пропорции, мультяшный стиль, 3D рендер, " +
"искусственный вид, водяной знак, текстовые надписи на изображении, " +
"нечёткий фон, пересвет, плохое качество, низкое разрешение";
// Один запрос в момент времени — Imagen 3 не любит параллельные запросы
private final Semaphore semaphore = new Semaphore(1); private final Semaphore semaphore = new Semaphore(1);
private volatile long lastRequestTimestamp = 0; private volatile long lastRequestTimestamp = 0;
// Минимальный интервал между запросами (15 сек — требование rate limit Imagen 3)
private static final long MIN_REQUEST_INTERVAL_MS = 15_000;
private final WebClient webClient; private final WebClient webClient;
@Value("${google.cloud.project-id}") @Value("${google.cloud.project-id}")
@@ -46,10 +69,11 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
public NanoBananaImageGenerationService() { public NanoBananaImageGenerationService() {
HttpClient httpClient = HttpClient.create() HttpClient httpClient = HttpClient.create()
.resolver(DefaultAddressResolverGroup.INSTANCE) .resolver(DefaultAddressResolverGroup.INSTANCE)
.responseTimeout(Duration.ofMillis(180000)); .responseTimeout(Duration.ofMillis(180_000));
ExchangeStrategies strategies = ExchangeStrategies.builder() ExchangeStrategies strategies = ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(30 * 1024 * 1024)) .codecs(configurer -> configurer.defaultCodecs()
.maxInMemorySize(30 * 1024 * 1024))
.build(); .build();
this.webClient = WebClient.builder() this.webClient = WebClient.builder()
@@ -58,11 +82,21 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
.build(); .build();
} }
// =====================================================================
// PUBLIC API
// =====================================================================
/**
* Базовая генерация — квадрат 1:1 (обратная совместимость).
*/
@Override @Override
public byte[] generateImage(String prompt) { public byte[] generateImage(String prompt) {
return generateImageWithReference(prompt, (byte[]) null); return generateImageInternal(prompt, null, "1:1");
} }
/**
* Генерация с логотипом — квадрат 1:1.
*/
public byte[] generateImage(String prompt, MultipartFile referenceLogo) { public byte[] generateImage(String prompt, MultipartFile referenceLogo) {
if (referenceLogo == null || referenceLogo.isEmpty()) { if (referenceLogo == null || referenceLogo.isEmpty()) {
return generateImage(prompt); return generateImage(prompt);
@@ -74,52 +108,169 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
} }
} }
/**
* Генерация с референсным изображением — квадрат 1:1.
* Используется для обратной совместимости со старым кодом.
*/
@Override
public byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes) { public byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes) {
return generateImageInternal(prompt, referenceImageBytes, "1:1");
}
/**
* ОСНОВНОЙ МЕТОД для генерации постов.
* Соотношение сторон выбирается автоматически по платформе:
* - Instagram → 4:5 (портретный, оптимален для ленты)
* - TikTok → 9:16 (вертикальный, полный экран)
* - Telegram → 4:5 (портретный)
* - LinkedIn → 4:5 (портретный)
* - По умолчанию → 4:5
*
* @param prompt Промпт на русском языке
* @param referenceImageBytes Байты логотипа/референса (может быть null)
* @param platform Платформа: "instagram", "tiktok", "telegram", "linkedin"
*/
public byte[] generateImageForPlatform(String prompt, byte[] referenceImageBytes, String platform) {
String aspectRatio = resolveAspectRatio(platform);
logger.info("[Imagen3] Platform='{}' → aspectRatio='{}'", platform, aspectRatio);
return generateImageInternal(prompt, referenceImageBytes, aspectRatio);
}
// =====================================================================
// CORE GENERATION
// =====================================================================
private byte[] generateImageInternal(String prompt, byte[] referenceImageBytes, String aspectRatio) {
if (projectId == null || projectId.trim().isEmpty()) { if (projectId == null || projectId.trim().isEmpty()) {
logger.error("[Imagen3] Project ID not configured!");
return null; return null;
} }
try { try {
semaphore.acquire(); semaphore.acquire();
// Rate limit — минимум 15 сек между запросами
long timeSinceLast = System.currentTimeMillis() - lastRequestTimestamp; long timeSinceLast = System.currentTimeMillis() - lastRequestTimestamp;
if (timeSinceLast < 15000) { if (timeSinceLast < MIN_REQUEST_INTERVAL_MS) {
Thread.sleep(15000 - timeSinceLast); long waitMs = MIN_REQUEST_INTERVAL_MS - timeSinceLast;
logger.debug("[Imagen3] Rate limit wait: {}ms", waitMs);
Thread.sleep(waitMs);
} }
String accessToken = getAccessTokenFromResources(); String accessToken = getAccessToken();
if (accessToken == null) { if (accessToken == null) {
logger.error("[Imagen3] Failed to get access token");
return null; return null;
} }
String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model); String endpointUrl = String.format(VERTEX_API_TEMPLATE,
String finalPrompt = prompt != null ? prompt : "High quality image"; location, projectId, location, model);
String finalPrompt = (prompt != null && !prompt.isBlank()) ? prompt : "Качественное изображение";
logger.info("[Imagen3] Generating. AspectRatio={}, PromptLen={}",
aspectRatio, finalPrompt.length());
try { try {
Map<String, Object> requestBody = buildImagenRequestBody(finalPrompt, referenceImageBytes); Map<String, Object> requestBody =
Map<String, Object> response = executeVertexRequest(endpointUrl, accessToken, requestBody); buildRequestBody(finalPrompt, referenceImageBytes, aspectRatio);
Map<String, Object> response =
executeRequest(endpointUrl, accessToken, requestBody);
lastRequestTimestamp = System.currentTimeMillis(); lastRequestTimestamp = System.currentTimeMillis();
return extractImageFromImagenResponse(response);
byte[] result = extractImage(response);
if (result != null) {
logger.info("[Imagen3] ✅ Success. Bytes={}", result.length);
} else {
logger.warn("[Imagen3] Empty result from API");
}
return result;
} catch (WebClientResponseException.BadRequest badRequestEx) { } catch (WebClientResponseException.BadRequest badRequestEx) {
logger.warn("[Imagen3] 400 BadRequest with reference — retrying without reference. Body: {}",
badRequestEx.getResponseBodyAsString());
// Если референс вызвал ошибку — пробуем без него
if (referenceImageBytes != null && referenceImageBytes.length > 0) { if (referenceImageBytes != null && referenceImageBytes.length > 0) {
Map<String, Object> fallbackBody = buildImagenRequestBody(finalPrompt, null); Map<String, Object> fallbackBody =
Map<String, Object> fallbackResponse = executeVertexRequest(endpointUrl, accessToken, fallbackBody); buildRequestBody(finalPrompt, null, aspectRatio);
Map<String, Object> fallbackResponse =
executeRequest(endpointUrl, accessToken, fallbackBody);
lastRequestTimestamp = System.currentTimeMillis(); lastRequestTimestamp = System.currentTimeMillis();
return extractImageFromImagenResponse(fallbackResponse); return extractImage(fallbackResponse);
} }
return null; return null;
} catch (Exception ex) { } catch (Exception ex) {
logger.error("[Imagen3] Generation error: {}", ex.getMessage());
return null; return null;
} }
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("[Imagen3] Interrupted");
return null;
} catch (Exception e) { } catch (Exception e) {
logger.error("[Imagen3] Fatal: {}", e.getMessage(), e);
return null; return null;
} finally { } finally {
semaphore.release(); semaphore.release();
} }
} }
private Map<String, Object> executeVertexRequest(String endpointUrl, String accessToken, Map<String, Object> requestBody) { // =====================================================================
// REQUEST BUILDING
// =====================================================================
private Map<String, Object> buildRequestBody(String prompt,
byte[] referenceImageBytes,
String aspectRatio) {
Map<String, Object> body = new HashMap<>();
List<Map<String, Object>> instances = new ArrayList<>();
Map<String, Object> instance = new HashMap<>();
// Промпт — только на русском, без добавления английских инструкций
instance.put("prompt", prompt);
// Референсное изображение (логотип клиента) — если передан
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
String base64Image = Base64.getEncoder().encodeToString(referenceImageBytes);
Map<String, Object> imageBytesMap = new HashMap<>();
imageBytesMap.put("bytesBase64Encoded", base64Image);
Map<String, Object> referenceImageParams = new HashMap<>();
referenceImageParams.put("referenceImage", imageBytesMap);
// STYLE — используем стиль лого, не SUBJECT (SUBJECT часто вызывает 400)
referenceImageParams.put("referenceType", "STYLE");
instance.put("referenceImages", List.of(referenceImageParams));
logger.debug("[Imagen3] Using reference image ({} bytes)", referenceImageBytes.length);
}
instances.add(instance);
body.put("instances", instances);
// Параметры генерации
Map<String, Object> parameters = new HashMap<>();
parameters.put("sampleCount", 1);
parameters.put("aspectRatio", aspectRatio);
parameters.put("safetyFilterLevel", "block_some");
parameters.put("personGeneration", "allow_adult");
parameters.put("negativePrompt", NEGATIVE_PROMPT_RU);
body.put("parameters", parameters);
return body;
}
// =====================================================================
// HTTP
// =====================================================================
private Map<String, Object> executeRequest(String endpointUrl,
String accessToken,
Map<String, Object> requestBody) {
try { try {
return webClient.post() return webClient.post()
.uri(URI.create(endpointUrl)) .uri(URI.create(endpointUrl))
@@ -128,25 +279,37 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
.bodyValue(requestBody) .bodyValue(requestBody)
.retrieve() .retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {}) .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.retryWhen(reactor.util.retry.Retry.backoff(10, Duration.ofSeconds(10)) // Retry только на 429 (rate limit)
.retryWhen(reactor.util.retry.Retry
.backoff(5, Duration.ofSeconds(10))
.maxBackoff(Duration.ofSeconds(60)) .maxBackoff(Duration.ofSeconds(60))
.filter(t -> t instanceof WebClientResponseException && .filter(t -> t instanceof WebClientResponseException
((WebClientResponseException) t).getStatusCode().value() == 429)) && ((WebClientResponseException) t).getStatusCode().value() == 429)
.block(Duration.ofMillis(180000)); .doBeforeRetry(signal ->
logger.warn("[Imagen3] 429 rate limit — retrying ({}/5)...",
signal.totalRetries() + 1)))
.block(Duration.ofMillis(180_000));
} catch (WebClientResponseException e) { } catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 400) { if (e.getStatusCode().value() == 400) {
throw e; throw e; // Пробрасываем для обработки в вызывающем коде (retry без референса)
} }
logger.error("[Imagen3] HTTP {}: {}", e.getStatusCode(), e.getResponseBodyAsString());
return null; return null;
} catch (Exception e) { } catch (Exception e) {
logger.error("[Imagen3] Request error: {}", e.getMessage());
return null; return null;
} }
} }
private String getAccessTokenFromResources() { // =====================================================================
// AUTH
// =====================================================================
private String getAccessToken() {
try { try {
ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH); ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH);
if (!resource.exists()) { if (!resource.exists()) {
logger.error("[Imagen3] Credentials file not found: {}", CREDENTIALS_FILE_PATH);
return null; return null;
} }
try (InputStream is = resource.getInputStream()) { try (InputStream is = resource.getInputStream()) {
@@ -156,64 +319,60 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
return credentials.getAccessToken().getTokenValue(); return credentials.getAccessToken().getTokenValue();
} }
} catch (IOException e) { } catch (IOException e) {
logger.error("[Imagen3] Failed to get access token: {}", e.getMessage());
return null; return null;
} }
} }
private Map<String, Object> buildImagenRequestBody(String prompt, byte[] referenceImageBytes) { // =====================================================================
Map<String, Object> body = new HashMap<>(); // RESPONSE PARSING
List<Map<String, Object>> instances = new ArrayList<>(); // =====================================================================
Map<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt);
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
String base64Image = Base64.getEncoder().encodeToString(referenceImageBytes);
Map<String, Object> referenceImageParams = new HashMap<>();
Map<String, Object> imageBytesMap = new HashMap<>();
imageBytesMap.put("bytesBase64Encoded", base64Image);
referenceImageParams.put("referenceImage", imageBytesMap);
referenceImageParams.put("referenceType", "SUBJECT");
List<Map<String, Object>> refImagesList = new ArrayList<>();
refImagesList.add(referenceImageParams);
instance.put("referenceImages", refImagesList);
instance.put("prompt", prompt + ". CRITICAL: Preserve the text, letters, and typography of the provided logo EXACTLY as they appear. Do not invent new text, do not distort letters. Integrate naturally.");
}
instances.add(instance);
body.put("instances", instances);
Map<String, Object> parameters = new HashMap<>();
parameters.put("sampleCount", 1);
parameters.put("aspectRatio", "1:1");
parameters.put("safetyFilterLevel", "block_some");
parameters.put("personGeneration", "allow_adult");
parameters.put("negativePrompt", "gibberish text, alien language, wrong spelling, distorted letters, bad anatomy, 6 fingers, 3 arms, mutated, distorted, extra limbs, bad proportions, unnatural bodies, nsfw, watermark");
body.put("parameters", parameters);
return body;
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private byte[] extractImageFromImagenResponse(Map<String, Object> response) { private byte[] extractImage(Map<String, Object> response) {
if (response == null) return null; if (response == null) return null;
try { try {
List<Map<String, Object>> predictions = (List<Map<String, Object>>) response.get("predictions"); List<Map<String, Object>> predictions =
(List<Map<String, Object>>) response.get("predictions");
if (predictions != null && !predictions.isEmpty()) { if (predictions != null && !predictions.isEmpty()) {
Map<String, Object> firstPrediction = predictions.get(0); Map<String, Object> first = predictions.get(0);
String base64Image = (String) firstPrediction.get("bytesBase64Encoded"); // Imagen 3 возвращает bytesBase64Encoded
if (base64Image == null) { String base64 = (String) first.get("bytesBase64Encoded");
base64Image = (String) firstPrediction.get("b64"); if (base64 == null) {
// Fallback — старый ключ
base64 = (String) first.get("b64");
} }
if (base64Image != null) { if (base64 != null && !base64.isBlank()) {
return java.util.Base64.getDecoder().decode(base64Image); return Base64.getDecoder().decode(base64);
} }
} }
} catch (Exception e) { } catch (Exception e) {
logger.error("[Imagen3] Failed to extract image from response: {}", e.getMessage());
} }
return null; return null;
} }
// =====================================================================
// UTILS
// =====================================================================
/**
* Выбирает правильное соотношение сторон под платформу.
*
* Imagen 3 поддерживает: 1:1, 4:3, 3:4, 16:9, 9:16
*
* Instagram лента → 4:5 НЕ поддерживается Imagen 3, поэтому используем 3:4 (близко к 4:5)
* TikTok / Reels → 9:16 (вертикальный полный экран)
*/
private String resolveAspectRatio(String platform) {
if (platform == null) return "3:4";
return switch (platform.toLowerCase().trim()) {
case "tiktok" -> "9:16"; // вертикальный для TikTok Reels
case "instagram" -> "3:4"; // портретный для Instagram ленты (ближайший к 4:5)
case "telegram" -> "3:4"; // портретный
case "linkedin" -> "4:3"; // горизонтальный для LinkedIn
case "youtube" -> "16:9"; // горизонтальный для YouTube
default -> "3:4"; // по умолчанию портретный
};
}
} }