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) {
title = title.substring(0, 77) + "...";
}
response.setAnalysisTitle(title);
}
});
@@ -25,12 +25,23 @@ import java.time.Duration;
import java.util.*;
/**
* Сервис генерации видео через Google Veo 3.
* Сервис генерации видео через Google Veo 3 (с fallback на Veo 2).
*
* КЛЮЧЕВЫЕ ОСОБЕННОСТИ Veo 3:
* - Нативный звук: музыка, диалоги, фоновые звуки — без доп. настроек
* - enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский
* - Все промпты передаются на русском языке для казахстанского рынка
* ══ ЗВУК В 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 недоступен (модель ещё не в вашем проекте или 404/403) —
* автоматически пробуем Veo 2. Это решает проблему "видео не генерируются".
*
* ══ ПРОМПТЫ ══
* enhancePrompt = false — ОБЯЗАТЕЛЬНО, иначе Google переводит промпт на английский.
* Все промпты на русском — для казахстанского рынка.
*/
@Service
public class GeminiVideoGenerationService {
@@ -38,6 +49,9 @@ 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();
@@ -76,32 +90,32 @@ public class GeminiVideoGenerationService {
this.credentials = GoogleCredentials.fromStream(is)
.createScoped(Collections.singletonList(
"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);
}
} 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) {
logger.error("VEO3: Error reading credentials: {}", e.getMessage());
logger.error("VEO: Error reading credentials: {}", e.getMessage());
}
}
private String getValidAccessToken() {
if (this.credentials == null) {
logger.error("VEO3: Credentials not initialized!");
logger.error("VEO: Credentials not initialized!");
return null;
}
try {
this.credentials.refreshIfExpired();
String token = this.credentials.getAccessToken().getTokenValue();
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 token;
} catch (IOException e) {
logger.error("VEO3: Token refresh error: {}", e.getMessage());
logger.error("VEO: Token refresh error: {}", e.getMessage());
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) {
if (projectId == null || projectId.trim().isEmpty()) {
logger.error("VEO3: Project ID is not configured!");
logger.error("VEO: Project ID not configured!");
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.");
}
}
return result;
}
/**
* Генерация через конкретную модель Veo.
*/
private byte[] generateWithModel(String prompt, String modelName, String accessToken) {
try {
// ═══ ШАГ 1: Запуск генерации через predictLongRunning ════════════
// ═══ ШАГ 1: Запуск генерации ════════════════════════════════════
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, modelName);
Map<String, Object> instance = new HashMap<>();
instance.put("prompt", prompt);
@@ -134,21 +175,25 @@ public class GeminiVideoGenerationService {
Map<String, Object> parameters = new HashMap<>();
parameters.put("aspectRatio", "9:16");
parameters.put("sampleCount", 1);
// КРИТИЧНО: false — не давать Google переводить промпт на английский
// КРИТИЧНО: false — иначе Google переводит промпт на английский!
parameters.put("enhancePrompt", false);
// Негативный промпт тоже на русском
// Негативный промпт на русском — единый язык с основным промптом
parameters.put("negativePrompt",
"размытое изображение, плохая анатомия, лишние конечности, деформированные лица, " +
"мультяшный стиль, 3D рендер, искусственный вид, субтитры на английском, " +
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы");
"размытое изображение, плохая анатомия, лишние конечности, " +
"деформированные лица, мультяшный стиль, 3D рендер, " +
"искусственный неестественный вид, субтитры на английском, " +
"водяной знак, нереалистичное движение, некрасивые руки, лишние пальцы, " +
"дёрганые движения, низкое качество, пиксели");
// ВАЖНО: НЕ добавляем generateAudio:true — это вызывает 400 через Vertex AI REST.
// Звук в Veo 3 активируется через описание в промпте (работает через Vertex AI).
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("instances", Collections.singletonList(instance));
requestBody.put("parameters", parameters);
logger.info("VEO3: Starting generation. Model={}, Endpoint={}", model, generateEndpoint);
logger.info("VEO3: Prompt (preview 300): '{}'",
prompt.length() > 300 ? prompt.substring(0, 300) + "..." : prompt);
logger.info("VEO: Starting. Model={}", modelName);
logger.info("VEO: Prompt preview: '{}'",
prompt.length() > 250 ? prompt.substring(0, 250) + "..." : prompt);
Map<String, Object> initResponse;
try {
@@ -159,46 +204,45 @@ public class GeminiVideoGenerationService {
.bodyValue(requestBody)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block(Duration.ofSeconds(120));
.block(Duration.ofSeconds(60));
} catch (WebClientResponseException e) {
logger.error("VEO3: HTTP {} on initial request. Body: {}",
e.getStatusCode(), e.getResponseBodyAsString());
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("VEO3: Initial response is null!");
logger.error("VEO: Null initial response for model={}", modelName);
return null;
}
logger.info("VEO3: Initial response: {}", objectMapper.writeValueAsString(initResponse));
String operationName = (String) initResponse.get("name");
if (operationName == null || operationName.isEmpty()) {
logger.error("VEO3: No 'name' field! Full response: {}",
objectMapper.writeValueAsString(initResponse));
logger.error("VEO: No 'name' in response! model={} response={}",
modelName, objectMapper.writeValueAsString(initResponse));
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(
"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<>();
fetchBody.put("operationName", operationName);
int attempts = 0;
int maxAttempts = 120; // 30 минут (120 × 15 сек)
int maxAttempts = 120; // 30 минут
while (attempts < maxAttempts) {
Thread.sleep(15000);
Thread.sleep(15_000);
attempts++;
String currentToken = getValidAccessToken();
if (currentToken == null) {
logger.warn("VEO3: No token on attempt {}, skipping...", attempts);
logger.warn("VEO: No token on attempt {}", attempts);
continue;
}
@@ -213,15 +257,16 @@ public class GeminiVideoGenerationService {
.block(Duration.ofSeconds(60));
if (statusResponse == null) {
logger.warn("VEO3: Null status on attempt {}", attempts);
logger.warn("VEO: Null status on attempt {}", attempts);
continue;
}
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")) {
logger.error("VEO3: Google returned error: {}",
logger.error("VEO: Error in response: {}",
objectMapper.writeValueAsString(statusResponse.get("error")));
return null;
}
@@ -231,33 +276,33 @@ public class GeminiVideoGenerationService {
|| "true".equalsIgnoreCase(String.valueOf(doneObj));
if (!isDone) {
logger.info("VEO3: Still processing... {}/{}", attempts, maxAttempts);
logger.info("VEO: Still processing {}/{}", attempts, maxAttempts);
continue;
}
logger.info("VEO3: DONE on attempt {}! Extracting...", attempts);
logger.info("VEO: DONE on attempt {}! Extracting video...", attempts);
// ═══ ШАГ 3: Извлечение видео ═════════════════════════════
@SuppressWarnings("unchecked")
Map<String, Object> responseObj =
(Map<String, Object>) statusResponse.get("response");
if (responseObj == null) {
logger.warn("VEO3: No 'response' field, trying root...");
logger.warn("VEO: No 'response' field trying root...");
responseObj = statusResponse;
}
// Вариант 1 — base64
String b64 = findFirstValue(responseObj, "bytesBase64Encoded");
if (b64 != null && !b64.isEmpty()) {
logger.info("VEO3: Found base64 (length={})", b64.length());
return decodeBase64Video(b64);
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("VEO3: Found video URI: {}", videoUri);
logger.info("VEO: Found URI: {}", videoUri);
return downloadFromGcs(videoUri, currentToken);
}
@@ -267,87 +312,78 @@ public class GeminiVideoGenerationService {
(List<Map<String, Object>>) responseObj.get("predictions");
if (predictions != null && !predictions.isEmpty()) {
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");
if (b64 != null && !b64.isEmpty()) return decodeBase64Video(b64);
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("VEO3: Video not found in response! Full dump: {}", rawJson);
logger.error("VEO: Video not found! Full: {}", rawJson);
return null;
} catch (WebClientResponseException e) {
logger.error("VEO3: HTTP error on attempt {}: {} — {}",
attempts, e.getStatusCode(), e.getResponseBodyAsString());
logger.error("VEO: HTTP {} on attempt {}: {}",
e.getStatusCode(), attempts, e.getResponseBodyAsString());
if (e.getStatusCode().is4xxClientError()) {
logger.error("VEO3: 4xx — stopping poll.");
logger.error("VEO: 4xx — stopping poll for model={}", modelName);
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;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("VEO3: Thread interrupted");
logger.error("VEO: Interrupted");
return null;
} catch (Exception e) {
logger.error("VEO3: Fatal error: {}", e.getMessage(), e);
logger.error("VEO: Fatal error (model={}): {}", modelName, e.getMessage(), e);
return null;
}
}
private byte[] decodeBase64Video(String b64) {
private byte[] decodeBase64(String b64) {
try {
String clean = b64.replaceAll("\\s", "");
if (clean.contains("base64,")) {
clean = clean.substring(clean.indexOf("base64,") + 7);
}
if (clean.contains("base64,")) clean = clean.substring(clean.indexOf("base64,") + 7);
byte[] bytes = Base64.getDecoder().decode(clean);
logger.info("VEO3: Decoded {} bytes from base64", bytes.length);
logger.info("VEO: Decoded {} bytes", bytes.length);
return bytes;
} catch (Exception e) {
logger.error("VEO3: Failed to decode base64: {}", e.getMessage());
logger.error("VEO: base64 decode failed: {}", e.getMessage());
return null;
}
}
private byte[] downloadFromGcs(String uri, String accessToken) {
try {
String downloadUrl;
if (uri.startsWith("gs://")) {
downloadUrl = "https://storage.googleapis.com/" + uri.substring(5);
logger.info("VEO3: GCS → HTTPS: {}", downloadUrl);
} else if (uri.startsWith("https://")) {
downloadUrl = uri;
} else {
logger.error("VEO3: Unknown URI format: {}", uri);
return null;
}
String downloadUrl = uri.startsWith("gs://")
? "https://storage.googleapis.com/" + uri.substring(5)
: uri;
logger.info("VEO: Downloading from: {}", downloadUrl);
byte[] videoBytes = webClient.get()
byte[] bytes = 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("VEO3: Downloaded {} bytes from GCS", videoBytes.length);
return videoBytes;
} else {
logger.error("VEO3: Empty download from: {}", uri);
return null;
if (bytes != null && bytes.length > 0) {
logger.info("VEO: Downloaded {} bytes from GCS", bytes.length);
return bytes;
}
logger.error("VEO: Empty download from: {}", uri);
return null;
} catch (Exception e) {
logger.error("VEO3: Download error from {}: {}", uri, e.getMessage());
logger.error("VEO: GCS download error {}: {}", uri, e.getMessage());
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.concurrent.Semaphore;
/**
* Сервис генерации изображений через Google Imagen 3.
*
* УЛУЧШЕНИЯ:
* - Соотношение сторон зависит от платформы (4:5 для Instagram, 9:16 для TikTok/Reels)
* - Негативный промпт на русском — единый язык с основным промптом
* - Референс-изображение не перебивает основной промпт английскими инструкциями
* - Метод generateImageForPlatform() для точного контроля формата
*/
@Service
public class NanoBananaImageGenerationService implements ImageGenerationService {
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 NEGATIVE_PROMPT_RU =
"размытое изображение, плохая анатомия, лишние конечности, шесть пальцев, три руки, " +
"деформированное тело, неправильные пропорции, мультяшный стиль, 3D рендер, " +
"искусственный вид, водяной знак, текстовые надписи на изображении, " +
"нечёткий фон, пересвет, плохое качество, низкое разрешение";
// Один запрос в момент времени — Imagen 3 не любит параллельные запросы
private final Semaphore semaphore = new Semaphore(1);
private volatile long lastRequestTimestamp = 0;
// Минимальный интервал между запросами (15 сек — требование rate limit Imagen 3)
private static final long MIN_REQUEST_INTERVAL_MS = 15_000;
private final WebClient webClient;
@Value("${google.cloud.project-id}")
@@ -46,10 +69,11 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
public NanoBananaImageGenerationService() {
HttpClient httpClient = HttpClient.create()
.resolver(DefaultAddressResolverGroup.INSTANCE)
.responseTimeout(Duration.ofMillis(180000));
.responseTimeout(Duration.ofMillis(180_000));
ExchangeStrategies strategies = ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(30 * 1024 * 1024))
.codecs(configurer -> configurer.defaultCodecs()
.maxInMemorySize(30 * 1024 * 1024))
.build();
this.webClient = WebClient.builder()
@@ -58,11 +82,21 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
.build();
}
// =====================================================================
// PUBLIC API
// =====================================================================
/**
* Базовая генерация — квадрат 1:1 (обратная совместимость).
*/
@Override
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) {
if (referenceLogo == null || referenceLogo.isEmpty()) {
return generateImage(prompt);
@@ -74,52 +108,169 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
}
}
/**
* Генерация с референсным изображением — квадрат 1:1.
* Используется для обратной совместимости со старым кодом.
*/
@Override
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()) {
logger.error("[Imagen3] Project ID not configured!");
return null;
}
try {
semaphore.acquire();
// Rate limit — минимум 15 сек между запросами
long timeSinceLast = System.currentTimeMillis() - lastRequestTimestamp;
if (timeSinceLast < 15000) {
Thread.sleep(15000 - timeSinceLast);
if (timeSinceLast < MIN_REQUEST_INTERVAL_MS) {
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) {
logger.error("[Imagen3] Failed to get access token");
return null;
}
String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model);
String finalPrompt = prompt != null ? prompt : "High quality image";
String endpointUrl = String.format(VERTEX_API_TEMPLATE,
location, projectId, location, model);
String finalPrompt = (prompt != null && !prompt.isBlank()) ? prompt : "Качественное изображение";
logger.info("[Imagen3] Generating. AspectRatio={}, PromptLen={}",
aspectRatio, finalPrompt.length());
try {
Map<String, Object> requestBody = buildImagenRequestBody(finalPrompt, referenceImageBytes);
Map<String, Object> response = executeVertexRequest(endpointUrl, accessToken, requestBody);
Map<String, Object> requestBody =
buildRequestBody(finalPrompt, referenceImageBytes, aspectRatio);
Map<String, Object> response =
executeRequest(endpointUrl, accessToken, requestBody);
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) {
logger.warn("[Imagen3] 400 BadRequest with reference — retrying without reference. Body: {}",
badRequestEx.getResponseBodyAsString());
// Если референс вызвал ошибку — пробуем без него
if (referenceImageBytes != null && referenceImageBytes.length > 0) {
Map<String, Object> fallbackBody = buildImagenRequestBody(finalPrompt, null);
Map<String, Object> fallbackResponse = executeVertexRequest(endpointUrl, accessToken, fallbackBody);
Map<String, Object> fallbackBody =
buildRequestBody(finalPrompt, null, aspectRatio);
Map<String, Object> fallbackResponse =
executeRequest(endpointUrl, accessToken, fallbackBody);
lastRequestTimestamp = System.currentTimeMillis();
return extractImageFromImagenResponse(fallbackResponse);
return extractImage(fallbackResponse);
}
return null;
} catch (Exception ex) {
logger.error("[Imagen3] Generation error: {}", ex.getMessage());
return null;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("[Imagen3] Interrupted");
return null;
} catch (Exception e) {
logger.error("[Imagen3] Fatal: {}", e.getMessage(), e);
return null;
} finally {
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 {
return webClient.post()
.uri(URI.create(endpointUrl))
@@ -128,25 +279,37 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
.bodyValue(requestBody)
.retrieve()
.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))
.filter(t -> t instanceof WebClientResponseException &&
((WebClientResponseException) t).getStatusCode().value() == 429))
.block(Duration.ofMillis(180000));
.filter(t -> t instanceof WebClientResponseException
&& ((WebClientResponseException) t).getStatusCode().value() == 429)
.doBeforeRetry(signal ->
logger.warn("[Imagen3] 429 rate limit — retrying ({}/5)...",
signal.totalRetries() + 1)))
.block(Duration.ofMillis(180_000));
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 400) {
throw e;
throw e; // Пробрасываем для обработки в вызывающем коде (retry без референса)
}
logger.error("[Imagen3] HTTP {}: {}", e.getStatusCode(), e.getResponseBodyAsString());
return null;
} catch (Exception e) {
logger.error("[Imagen3] Request error: {}", e.getMessage());
return null;
}
}
private String getAccessTokenFromResources() {
// =====================================================================
// AUTH
// =====================================================================
private String getAccessToken() {
try {
ClassPathResource resource = new ClassPathResource(CREDENTIALS_FILE_PATH);
if (!resource.exists()) {
logger.error("[Imagen3] Credentials file not found: {}", CREDENTIALS_FILE_PATH);
return null;
}
try (InputStream is = resource.getInputStream()) {
@@ -156,64 +319,60 @@ public class NanoBananaImageGenerationService implements ImageGenerationService
return credentials.getAccessToken().getTokenValue();
}
} catch (IOException e) {
logger.error("[Imagen3] Failed to get access token: {}", e.getMessage());
return null;
}
}
private Map<String, Object> buildImagenRequestBody(String prompt, byte[] referenceImageBytes) {
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> 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;
}
// =====================================================================
// RESPONSE PARSING
// =====================================================================
@SuppressWarnings("unchecked")
private byte[] extractImageFromImagenResponse(Map<String, Object> response) {
private byte[] extractImage(Map<String, Object> response) {
if (response == null) return null;
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()) {
Map<String, Object> firstPrediction = predictions.get(0);
String base64Image = (String) firstPrediction.get("bytesBase64Encoded");
if (base64Image == null) {
base64Image = (String) firstPrediction.get("b64");
Map<String, Object> first = predictions.get(0);
// Imagen 3 возвращает bytesBase64Encoded
String base64 = (String) first.get("bytesBase64Encoded");
if (base64 == null) {
// Fallback — старый ключ
base64 = (String) first.get("b64");
}
if (base64Image != null) {
return java.util.Base64.getDecoder().decode(base64Image);
if (base64 != null && !base64.isBlank()) {
return Base64.getDecoder().decode(base64);
}
}
} catch (Exception e) {
logger.error("[Imagen3] Failed to extract image from response: {}", e.getMessage());
}
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"; // по умолчанию портретный
};
}
}