generated from fahricansecer/boilerplate-be
+2
-2
@@ -7,7 +7,7 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache openssl libc6-compat
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
# pnpm kurulumu (workspace kuralı gereği)
|
# pnpm kurulumu (workspace kuralı gereği)
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
|
|
||||||
# Paket dosyalarını kopyala
|
# Paket dosyalarını kopyala
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
@@ -29,7 +29,7 @@ FROM node:20-alpine AS production
|
|||||||
RUN apk add --no-cache openssl libc6-compat
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
# pnpm kurulumu
|
# pnpm kurulumu
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -1,12 +1,18 @@
|
|||||||
const { PrismaClient } = require('@prisma/client');
|
const { PrismaClient } = require('@prisma/client');
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function check() {
|
||||||
const admin = await prisma.user.findFirst({
|
const episodes = await prisma.tubeStrategistEpisode.findMany({
|
||||||
where: { email: 'admin@contentgen.ai' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: { roles: { include: { role: true } } }
|
take: 1
|
||||||
});
|
});
|
||||||
console.log(JSON.stringify(admin, null, 2));
|
|
||||||
|
if (episodes.length > 0) {
|
||||||
|
const analysis = episodes[0].masterAnalysis;
|
||||||
|
console.log("Thumbnail URL:", JSON.stringify(analysis.thumbnailUrl, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log("No episodes found.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
check().catch(console.error).finally(() => prisma.$disconnect());
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ try
|
|||||||
builder.Services.AddHttpClient<MinimaxTtsService>("MinimaxTTS")
|
builder.Services.AddHttpClient<MinimaxTtsService>("MinimaxTTS")
|
||||||
.AddPolicyHandler(combinedPolicy);
|
.AddPolicyHandler(combinedPolicy);
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<VoiceboxTtsService>("VoiceboxTTS")
|
||||||
|
.AddPolicyHandler(combinedPolicy);
|
||||||
|
|
||||||
builder.Services.AddHttpClient<SunoMusicService>("Suno")
|
builder.Services.AddHttpClient<SunoMusicService>("Suno")
|
||||||
.AddPolicyHandler(combinedPolicy);
|
.AddPolicyHandler(combinedPolicy);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class VideoRenderPipeline
|
|||||||
private readonly TtsService _tts;
|
private readonly TtsService _tts;
|
||||||
private readonly OpenAiTtsService _openAiTts;
|
private readonly OpenAiTtsService _openAiTts;
|
||||||
private readonly MinimaxTtsService _minimaxTts;
|
private readonly MinimaxTtsService _minimaxTts;
|
||||||
|
private readonly VoiceboxTtsService _voiceboxTts;
|
||||||
private readonly SunoMusicService _sunoMusic;
|
private readonly SunoMusicService _sunoMusic;
|
||||||
private readonly AudioCraftService _audioCraft;
|
private readonly AudioCraftService _audioCraft;
|
||||||
private readonly RemotionService _remotion;
|
private readonly RemotionService _remotion;
|
||||||
@@ -38,6 +39,7 @@ public class VideoRenderPipeline
|
|||||||
TtsService tts,
|
TtsService tts,
|
||||||
OpenAiTtsService openAiTts,
|
OpenAiTtsService openAiTts,
|
||||||
MinimaxTtsService minimaxTts,
|
MinimaxTtsService minimaxTts,
|
||||||
|
VoiceboxTtsService voiceboxTts,
|
||||||
SunoMusicService sunoMusic,
|
SunoMusicService sunoMusic,
|
||||||
AudioCraftService audioCraft,
|
AudioCraftService audioCraft,
|
||||||
RemotionService remotion,
|
RemotionService remotion,
|
||||||
@@ -50,6 +52,7 @@ public class VideoRenderPipeline
|
|||||||
_tts = tts;
|
_tts = tts;
|
||||||
_openAiTts = openAiTts;
|
_openAiTts = openAiTts;
|
||||||
_minimaxTts = minimaxTts;
|
_minimaxTts = minimaxTts;
|
||||||
|
_voiceboxTts = voiceboxTts;
|
||||||
_sunoMusic = sunoMusic;
|
_sunoMusic = sunoMusic;
|
||||||
_audioCraft = audioCraft;
|
_audioCraft = audioCraft;
|
||||||
_remotion = remotion;
|
_remotion = remotion;
|
||||||
@@ -276,6 +279,10 @@ public class VideoRenderPipeline
|
|||||||
{
|
{
|
||||||
result = await _minimaxTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct);
|
result = await _minimaxTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct);
|
||||||
}
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(scene.TtsProvider) && scene.TtsProvider.Equals("voicebox", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
result = await _voiceboxTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Default: ElevenLabs
|
// Default: ElevenLabs
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SaasMediaWorker.Configuration;
|
||||||
|
using SaasMediaWorker.Models;
|
||||||
|
|
||||||
|
namespace SaasMediaWorker.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VoiceBox AI Studio Client — Metin → Ses dönüşümü (%100 Yerel ve Ücretsiz).
|
||||||
|
/// </summary>
|
||||||
|
public class VoiceboxTtsService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<VoiceboxTtsService> _logger;
|
||||||
|
private readonly ApiSettings _settings;
|
||||||
|
|
||||||
|
public VoiceboxTtsService(
|
||||||
|
HttpClient httpClient,
|
||||||
|
ILogger<VoiceboxTtsService> logger,
|
||||||
|
IOptions<ApiSettings> settings)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
_settings = settings.Value;
|
||||||
|
|
||||||
|
// Docker ağı üzerinde voicebox servisine bağlanır
|
||||||
|
// C# projesinin appsettings.json dosyasından da okunabilir ama varsayılanı atıyoruz
|
||||||
|
_httpClient.BaseAddress = new Uri("http://contgen-ai-voicebox:17493/");
|
||||||
|
_httpClient.Timeout = TimeSpan.FromMinutes(5); // Yerel render işlemci hızına bağlı sürebilir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bir sahnenin narration metnini sese çevirir ve dosyaya kaydeder.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GeneratedMediaFile> GenerateNarrationAsync(
|
||||||
|
ScenePayload scene,
|
||||||
|
string outputDirectory,
|
||||||
|
string voiceStyle,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"🎙️ VoiceBox TTS üretimi — Sahne {Order}: \"{Text}\"",
|
||||||
|
scene.Order,
|
||||||
|
scene.NarrationText[..Math.Min(60, scene.NarrationText.Length)]);
|
||||||
|
|
||||||
|
// VoiceBox'ta varsayılan bir profil (Örn: Kokoro default)
|
||||||
|
var profileId = string.IsNullOrWhiteSpace(voiceStyle) ? "b6a8a474-0fc0-4a8f-b9f1-a1e4c84a8649" : voiceStyle;
|
||||||
|
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
text = scene.NarrationText,
|
||||||
|
profile_id = profileId,
|
||||||
|
language = "tr",
|
||||||
|
engine = "kokoro"
|
||||||
|
};
|
||||||
|
|
||||||
|
var content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(requestBody),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json");
|
||||||
|
|
||||||
|
// 1. Asenkron üretim başlat
|
||||||
|
var generateResponse = await _httpClient.PostAsync("generate", content, ct);
|
||||||
|
generateResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var genJsonStr = await generateResponse.Content.ReadAsStringAsync(ct);
|
||||||
|
using var genDoc = JsonDocument.Parse(genJsonStr);
|
||||||
|
var generationId = genDoc.RootElement.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(generationId))
|
||||||
|
{
|
||||||
|
throw new Exception("VoiceBox üretim başlatıldı ancak ID alınamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Durumu polling ile kontrol et
|
||||||
|
string status = "generating";
|
||||||
|
int attempts = 0;
|
||||||
|
int maxAttempts = 120; // 120 * 2sn = 4 dakika
|
||||||
|
|
||||||
|
while (status != "completed" && status != "failed" && attempts < maxAttempts && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(2000, ct);
|
||||||
|
|
||||||
|
var historyResponse = await _httpClient.GetAsync("history", ct);
|
||||||
|
if (historyResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var historyStr = await historyResponse.Content.ReadAsStringAsync(ct);
|
||||||
|
using var historyDoc = JsonDocument.Parse(historyStr);
|
||||||
|
|
||||||
|
JsonElement itemsElement;
|
||||||
|
if (historyDoc.RootElement.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
itemsElement = historyDoc.RootElement;
|
||||||
|
}
|
||||||
|
else if (historyDoc.RootElement.TryGetProperty("items", out var itemsProp) && itemsProp.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
itemsElement = itemsProp;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
continue; // Geçersiz format
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in itemsElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.GetProperty("id").GetString() == generationId)
|
||||||
|
{
|
||||||
|
if (item.TryGetProperty("status", out var statusProp))
|
||||||
|
{
|
||||||
|
status = statusProp.GetString() ?? "completed";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
status = "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == "failed")
|
||||||
|
{
|
||||||
|
var errorMsg = item.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Bilinmeyen üretim hatası";
|
||||||
|
throw new Exception($"VoiceBox ses üretemedi: {errorMsg}");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != "completed")
|
||||||
|
{
|
||||||
|
throw new Exception("VoiceBox ses üretimi zaman aşımına uğradı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Üretilen ses dosyasını indir
|
||||||
|
var audioResponse = await _httpClient.GetAsync($"audio/{generationId}", ct);
|
||||||
|
audioResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var audioBytes = await audioResponse.Content.ReadAsByteArrayAsync(ct);
|
||||||
|
|
||||||
|
var extension = audioResponse.Content.Headers.ContentType?.MediaType == "audio/mpeg" ? "mp3" : "wav";
|
||||||
|
var outputPath = Path.Combine(outputDirectory, $"scene_{scene.Order:D2}_narration.{extension}");
|
||||||
|
|
||||||
|
await File.WriteAllBytesAsync(outputPath, audioBytes, ct);
|
||||||
|
|
||||||
|
var fileInfo = new FileInfo(outputPath);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"VoiceBox TTS tamamlandı — Sahne {Order}: {Size} bytes",
|
||||||
|
scene.Order, fileInfo.Length);
|
||||||
|
|
||||||
|
return new GeneratedMediaFile
|
||||||
|
{
|
||||||
|
SceneId = scene.Id,
|
||||||
|
SceneOrder = scene.Order,
|
||||||
|
Type = MediaFileType.AudioNarration,
|
||||||
|
LocalPath = outputPath,
|
||||||
|
FileSizeBytes = fileInfo.Length,
|
||||||
|
DurationSeconds = scene.Duration,
|
||||||
|
MimeType = $"audio/{extension}",
|
||||||
|
AiProvider = "voicebox"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.964.0",
|
"@aws-sdk/client-s3": "^3.964.0",
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/cache-manager": "^3.1.0",
|
"@nestjs/cache-manager": "^3.1.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
|||||||
Generated
+19
-2
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@google/genai':
|
'@google/genai':
|
||||||
specifier: ^1.35.0
|
specifier: ^1.35.0
|
||||||
version: 1.47.0
|
version: 1.47.0
|
||||||
|
'@nestjs/axios':
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2)
|
||||||
'@nestjs/bullmq':
|
'@nestjs/bullmq':
|
||||||
specifier: ^11.0.4
|
specifier: ^11.0.4
|
||||||
version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bullmq@5.71.1)
|
version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(bullmq@5.71.1)
|
||||||
@@ -46,7 +49,7 @@ importers:
|
|||||||
version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
'@nestjs/terminus':
|
'@nestjs/terminus':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2))(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/throttler':
|
'@nestjs/throttler':
|
||||||
specifier: ^6.5.0
|
specifier: ^6.5.0
|
||||||
version: 6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)
|
version: 6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)
|
||||||
@@ -1136,6 +1139,13 @@ packages:
|
|||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
|
'@nestjs/axios@4.0.1':
|
||||||
|
resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
axios: ^1.3.1
|
||||||
|
rxjs: ^7.0.0
|
||||||
|
|
||||||
'@nestjs/bull-shared@11.0.4':
|
'@nestjs/bull-shared@11.0.4':
|
||||||
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
|
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5793,6 +5803,12 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
axios: 1.15.0
|
||||||
|
rxjs: 7.8.2
|
||||||
|
|
||||||
'@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)':
|
'@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -5948,7 +5964,7 @@ snapshots:
|
|||||||
class-transformer: 0.5.1
|
class-transformer: 0.5.1
|
||||||
class-validator: 0.14.4
|
class-validator: 0.14.4
|
||||||
|
|
||||||
'@nestjs/terminus@11.1.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
'@nestjs/terminus@11.1.1(@nestjs/axios@4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2))(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@prisma/client@5.22.0(prisma@5.22.0))(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -5957,6 +5973,7 @@ snapshots:
|
|||||||
reflect-metadata: 0.2.2
|
reflect-metadata: 0.2.2
|
||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.15.0)(rxjs@7.8.2)
|
||||||
'@prisma/client': 5.22.0(prisma@5.22.0)
|
'@prisma/client': 5.22.0(prisma@5.22.0)
|
||||||
|
|
||||||
'@nestjs/testing@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17)':
|
'@nestjs/testing@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-express@11.1.17)':
|
||||||
|
|||||||
@@ -731,6 +731,7 @@ model TubeStrategistProject {
|
|||||||
formatDescription String? @db.Text
|
formatDescription String? @db.Text
|
||||||
|
|
||||||
masterAnalysis Json? // Bütün master analizi burada duracak
|
masterAnalysis Json? // Bütün master analizi burada duracak
|
||||||
|
communityInsights Json? // Yorum/Transkript analizinden çıkan gelecek bölüm fikirleri ve virallik skorları
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
userId String
|
userId String
|
||||||
@@ -784,6 +785,9 @@ model TubeStrategistEpisode {
|
|||||||
|
|
||||||
status String @default("DRAFT") // DRAFT, ANALYZING, COMPLETED
|
status String @default("DRAFT") // DRAFT, ANALYZING, COMPLETED
|
||||||
masterAnalysis Json?
|
masterAnalysis Json?
|
||||||
|
sponsorshipPitch Json? // Sponsorluk e-posta şablonları ve kitle analizleri
|
||||||
|
thumbnailMatrix Json? // A/B Test başlıkları ve küçük resim konseptleri
|
||||||
|
shortsConcepts Json? // Ana bölümden çıkarılmış 3-5 adet kısa içerik fikri
|
||||||
|
|
||||||
projectId String
|
projectId String
|
||||||
project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project TubeStrategistProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await prisma.voiceProfile.create({
|
||||||
|
data: {
|
||||||
|
name: 'Özgür AI (Tüm Motorlar)',
|
||||||
|
description: 'İstediğiniz ses motorunu seçebileceğiniz serbest profil.',
|
||||||
|
voice_type: 'cloned',
|
||||||
|
language: 'tr',
|
||||||
|
tags: ['serbest', 'çok-dilli'],
|
||||||
|
preview_url: null,
|
||||||
|
personality: 'Sen yardımcı bir asistansın.',
|
||||||
|
default_engine: 'qwen'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('Profile created');
|
||||||
|
}
|
||||||
|
main().catch(e => {
|
||||||
|
console.error(e);
|
||||||
|
}).finally(() => prisma.$disconnect());
|
||||||
@@ -54,6 +54,7 @@ import { DashboardModule } from './modules/dashboard/dashboard.module';
|
|||||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
import { ExtractorModule } from './modules/extractor/extractor.module';
|
import { ExtractorModule } from './modules/extractor/extractor.module';
|
||||||
import { YoutubeToolsModule } from './modules/youtube-tools/youtube-tools.module';
|
import { YoutubeToolsModule } from './modules/youtube-tools/youtube-tools.module';
|
||||||
|
import { VoiceboxModule } from './modules/voicebox/voicebox.module';
|
||||||
|
|
||||||
// Guards
|
// Guards
|
||||||
import {
|
import {
|
||||||
@@ -204,6 +205,7 @@ import {
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
ExtractorModule,
|
ExtractorModule,
|
||||||
YoutubeToolsModule,
|
YoutubeToolsModule,
|
||||||
|
VoiceboxModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global Exception Filter
|
// Global Exception Filter
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Controller, Get, Post, Body, Res, HttpStatus, Param } from '@nestjs/common';
|
||||||
|
import { Public } from '../../common/decorators';
|
||||||
|
import { VoiceboxService } from './voicebox.service';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
@Controller('voicebox')
|
||||||
|
export class VoiceboxController {
|
||||||
|
constructor(private readonly voiceboxService: VoiceboxService) {}
|
||||||
|
|
||||||
|
@Get('profiles')
|
||||||
|
async getProfiles() {
|
||||||
|
return this.voiceboxService.getProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('history')
|
||||||
|
async getHistory() {
|
||||||
|
return this.voiceboxService.getHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('audio/:id')
|
||||||
|
async getAudio(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
const audioBuffer = await this.voiceboxService.getAudio(id);
|
||||||
|
const bufferToSend = Buffer.isBuffer(audioBuffer) ? audioBuffer : Buffer.from(audioBuffer);
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'audio/wav',
|
||||||
|
'Content-Disposition': `inline; filename="history_${id}.wav"`,
|
||||||
|
'Content-Length': bufferToSend.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(HttpStatus.OK).send(bufferToSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('generate')
|
||||||
|
async generateSpeech(
|
||||||
|
@Body() body: { text: string; profileId: string; language?: string; engine?: string; modelSize?: string; instruct?: string; seed?: number },
|
||||||
|
@Res() res: Response
|
||||||
|
) {
|
||||||
|
const { text, profileId, language, engine, modelSize, instruct, seed } = body;
|
||||||
|
const audioBuffer = await this.voiceboxService.generateSpeech(text, profileId, { language, engine, modelSize, instruct, seed });
|
||||||
|
const bufferToSend = Buffer.isBuffer(audioBuffer) ? audioBuffer : Buffer.from(audioBuffer);
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'audio/wav',
|
||||||
|
'Content-Disposition': `attachment; filename="voicebox_${Date.now()}.wav"`,
|
||||||
|
'Content-Length': bufferToSend.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(HttpStatus.OK).send(bufferToSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('speak')
|
||||||
|
async speak(@Body() body: { text: string; profile: string; personality?: boolean }) {
|
||||||
|
return this.voiceboxService.speak(body.text, body.profile, body.personality);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { VoiceboxController } from './voicebox.controller';
|
||||||
|
import { VoiceboxService } from './voicebox.service';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [HttpModule],
|
||||||
|
controllers: [VoiceboxController],
|
||||||
|
providers: [VoiceboxService],
|
||||||
|
exports: [VoiceboxService],
|
||||||
|
})
|
||||||
|
export class VoiceboxModule {}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
import * as FormData from 'form-data';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VoiceboxService {
|
||||||
|
private readonly logger = new Logger(VoiceboxService.name);
|
||||||
|
private readonly voiceboxUrl = 'http://contgen-ai-voicebox:17493'; // Docker network URL
|
||||||
|
|
||||||
|
constructor(private readonly httpService: HttpService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoiceBox'taki tüm ses profillerini getirir
|
||||||
|
*/
|
||||||
|
async getProfiles() {
|
||||||
|
try {
|
||||||
|
const response: any = await lastValueFrom(
|
||||||
|
this.httpService.get<any>(`${this.voiceboxUrl}/profiles`)
|
||||||
|
);
|
||||||
|
|
||||||
|
let profiles = response.data;
|
||||||
|
|
||||||
|
// Eğer VoiceBox veritabanı boşsa (ilk kurulum), varsayılan Kokoro profillerini oluştur
|
||||||
|
if (!profiles || (Array.isArray(profiles) && profiles.length === 0)) {
|
||||||
|
this.logger.log('VoiceBox DB is empty. Auto-provisioning default Kokoro profiles...');
|
||||||
|
|
||||||
|
const alloyProfile = {
|
||||||
|
name: 'Alloy (US)',
|
||||||
|
language: 'en',
|
||||||
|
voice_type: 'preset',
|
||||||
|
preset_engine: 'kokoro',
|
||||||
|
preset_voice_id: 'af_alloy',
|
||||||
|
default_engine: 'kokoro'
|
||||||
|
};
|
||||||
|
|
||||||
|
const adamProfile = {
|
||||||
|
name: 'Adam (US)',
|
||||||
|
language: 'en',
|
||||||
|
voice_type: 'preset',
|
||||||
|
preset_engine: 'kokoro',
|
||||||
|
preset_voice_id: 'am_adam',
|
||||||
|
default_engine: 'kokoro'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await lastValueFrom(this.httpService.post(`${this.voiceboxUrl}/profiles`, alloyProfile));
|
||||||
|
await lastValueFrom(this.httpService.post(`${this.voiceboxUrl}/profiles`, adamProfile));
|
||||||
|
|
||||||
|
// Yeniden çek
|
||||||
|
const retryResponse: any = await lastValueFrom(this.httpService.get<any>(`${this.voiceboxUrl}/profiles`));
|
||||||
|
profiles = retryResponse.data;
|
||||||
|
} catch (postError: any) {
|
||||||
|
this.logger.error('Failed to auto-provision default profiles', postError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge TTS profilleri kontrolü
|
||||||
|
const hasEdgeTTS = Array.isArray(profiles) && profiles.some((p: any) => p.preset_engine === 'edge_tts');
|
||||||
|
if (Array.isArray(profiles) && !hasEdgeTTS) {
|
||||||
|
this.logger.log('Auto-provisioning missing Edge TTS profiles...');
|
||||||
|
const ahmetProfile = { name: 'Ahmet (TR)', language: 'tr', voice_type: 'preset', preset_engine: 'edge_tts', preset_voice_id: 'tr-TR-AhmetNeural', default_engine: 'edge_tts' };
|
||||||
|
const emelProfile = { name: 'Emel (TR)', language: 'tr', voice_type: 'preset', preset_engine: 'edge_tts', preset_voice_id: 'tr-TR-EmelNeural', default_engine: 'edge_tts' };
|
||||||
|
try {
|
||||||
|
await lastValueFrom(this.httpService.post(`${this.voiceboxUrl}/profiles`, ahmetProfile));
|
||||||
|
await lastValueFrom(this.httpService.post(`${this.voiceboxUrl}/profiles`, emelProfile));
|
||||||
|
const retryResponse: any = await lastValueFrom(this.httpService.get<any>(`${this.voiceboxUrl}/profiles`));
|
||||||
|
profiles = retryResponse.data;
|
||||||
|
} catch (postError: any) {
|
||||||
|
this.logger.error('Failed to auto-provision Edge TTS profiles', postError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error fetching VoiceBox profiles', error.message);
|
||||||
|
throw new HttpException(
|
||||||
|
'VoiceBox profilleri alınamadı',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metinden ses üretir (TTS)
|
||||||
|
* @param text Sese dönüştürülecek metin (paralinguistic tag'ler içerebilir)
|
||||||
|
* @param profileId Kullanılacak ses profilinin ID'si veya adı
|
||||||
|
* @param language Hedef dil (örn: "tr")
|
||||||
|
*/
|
||||||
|
async generateSpeech(text: string, profileId: string, options: { language?: string, engine?: string, modelSize?: string, instruct?: string, seed?: number } = {}) {
|
||||||
|
try {
|
||||||
|
const profiles = await this.getProfiles();
|
||||||
|
let profile = profiles.find((p: any) => p.id === profileId || p.preset_voice_id === profileId);
|
||||||
|
|
||||||
|
let profileIdToUse = profileId;
|
||||||
|
|
||||||
|
// Eğer kullanıcı açıkça bir motor seçtiyse onu kullan, yoksa profilin varsayılanını al, o da yoksa kokoro kullan
|
||||||
|
const engine = options.engine || profile?.default_engine || profile?.preset_engine || 'kokoro';
|
||||||
|
|
||||||
|
// Uyumsuzluk Kontrolü: Eğer engine "edge_tts" ama seçilen profil kokoro vb. destekliyorsa veya profili bulamadıysak, otomatik Edge TTS profilini kullan
|
||||||
|
if (engine === 'edge_tts' && (!profile || profile.preset_engine !== 'edge_tts')) {
|
||||||
|
const edgeProfile = profiles.find((p: any) => p.preset_engine === 'edge_tts');
|
||||||
|
if (edgeProfile) {
|
||||||
|
profileIdToUse = edgeProfile.id;
|
||||||
|
profile = edgeProfile;
|
||||||
|
this.logger.warn(`Overriding incompatible profile ${profileId} with ${edgeProfile.name} because engine is edge_tts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
text,
|
||||||
|
profile_id: profileIdToUse,
|
||||||
|
language: options.language || 'tr',
|
||||||
|
engine
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.modelSize) payload.model_size = options.modelSize;
|
||||||
|
if (options.instruct) payload.instruct = options.instruct;
|
||||||
|
if (options.seed !== undefined && options.seed !== null) payload.seed = options.seed;
|
||||||
|
|
||||||
|
// Asenkron üretim başlat
|
||||||
|
const generateResponse: any = await lastValueFrom(
|
||||||
|
this.httpService.post<any>(`${this.voiceboxUrl}/generate`, payload)
|
||||||
|
);
|
||||||
|
const generationId = generateResponse.data.id;
|
||||||
|
|
||||||
|
// Durumu polling ile kontrol et
|
||||||
|
let status = 'generating';
|
||||||
|
let attempts = 0;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
while (status !== 'completed' && status !== 'failed' && attempts < 120) {
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
try {
|
||||||
|
const genResponse: any = await lastValueFrom(
|
||||||
|
this.httpService.get<any>(`${this.voiceboxUrl}/history/${generationId}`)
|
||||||
|
);
|
||||||
|
const gen = genResponse.data;
|
||||||
|
|
||||||
|
if (gen) {
|
||||||
|
status = gen.status || 'completed';
|
||||||
|
if (status === 'failed') {
|
||||||
|
throw new Error(gen.error || 'Bilinmeyen üretim hatası');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// If 404, it might still be generating or not inserted into DB yet, continue polling
|
||||||
|
if (err.response && err.response.status === 404) {
|
||||||
|
// keep generating
|
||||||
|
} else {
|
||||||
|
// Rethrow actual errors (like the one we throw above for status='failed')
|
||||||
|
if (err.message && err.message !== 'Request failed with status code 404') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== 'completed') {
|
||||||
|
throw new Error('Ses üretimi zaman aşımına uğradı');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getAudio(generationId);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error generating speech via VoiceBox', error.message);
|
||||||
|
throw new HttpException(
|
||||||
|
error.message || 'VoiceBox ses üretemedi',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoiceBox üretim geçmişini getirir
|
||||||
|
*/
|
||||||
|
async getHistory() {
|
||||||
|
try {
|
||||||
|
const response: any = await lastValueFrom(
|
||||||
|
this.httpService.get<any>(`${this.voiceboxUrl}/history`)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error fetching VoiceBox history', error.message);
|
||||||
|
throw new HttpException(
|
||||||
|
'VoiceBox üretim geçmişi alınamadı',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VoiceBox geçmiş ses dosyasını getirir
|
||||||
|
*/
|
||||||
|
async getAudio(generationId: string) {
|
||||||
|
try {
|
||||||
|
const response: any = await lastValueFrom(
|
||||||
|
this.httpService.get<any>(`${this.voiceboxUrl}/audio/${generationId}`, {
|
||||||
|
responseType: 'arraybuffer'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Error fetching VoiceBox audio for ${generationId}`, error.message);
|
||||||
|
throw new HttpException(
|
||||||
|
'Ses dosyası bulunamadı',
|
||||||
|
HttpStatus.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent / Script üzerinden direk ses çıkışı yapar
|
||||||
|
* @param text Söylenecek metin
|
||||||
|
* @param profile Kullanılacak profil
|
||||||
|
* @param personality Karakter uygulanacaksa true
|
||||||
|
*/
|
||||||
|
async speak(text: string, profile: string, personality: boolean = false) {
|
||||||
|
try {
|
||||||
|
const response: any = await lastValueFrom(
|
||||||
|
this.httpService.post<any>(`${this.voiceboxUrl}/speak`, {
|
||||||
|
text,
|
||||||
|
profile,
|
||||||
|
personality
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'X-Voicebox-Client-Id': 'contgen-ai-backend'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Error triggering VoiceBox speak', error.message);
|
||||||
|
throw new HttpException(
|
||||||
|
'VoiceBox konuşma işlemi başarısız',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Innertube } from 'youtubei.js';
|
|||||||
import { YoutubeTranscript } from 'youtube-transcript';
|
import { YoutubeTranscript } from 'youtube-transcript';
|
||||||
import { GeminiService } from '../gemini/gemini.service';
|
import { GeminiService } from '../gemini/gemini.service';
|
||||||
import { VideoAiService } from '../video-ai/video-ai.service';
|
import { VideoAiService } from '../video-ai/video-ai.service';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TubeStrategistService {
|
export class TubeStrategistService {
|
||||||
@@ -18,7 +19,8 @@ export class TubeStrategistService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly videoAiService: VideoAiService,
|
private readonly videoAiService: VideoAiService,
|
||||||
private readonly geminiService: GeminiService
|
private readonly geminiService: GeminiService,
|
||||||
|
private readonly storageService: StorageService
|
||||||
) {
|
) {
|
||||||
const apiKey =
|
const apiKey =
|
||||||
this.configService.get<string>('gemini.apiKey') ||
|
this.configService.get<string>('gemini.apiKey') ||
|
||||||
@@ -670,7 +672,23 @@ export class TubeStrategistService {
|
|||||||
data: updateData
|
data: updateData
|
||||||
});
|
});
|
||||||
|
|
||||||
return mergedAnalysis;
|
// Otomatik olarak tüm modüler analizleri çalıştır ve veritabanına kaydet
|
||||||
|
try {
|
||||||
|
this.logger.log(`[analyzeEpisode] Modüler analizler başlatılıyor... (${episodeId})`);
|
||||||
|
await this.generateEpisodeQuestions(episodeId, userId);
|
||||||
|
await this.generateEpisodeSeoMarketing(episodeId, userId);
|
||||||
|
await this.generateEpisodeCrisisSponsors(episodeId, userId);
|
||||||
|
this.logger.log(`[analyzeEpisode] Modüler analizler tamamlandı. (${episodeId})`);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.error(`[analyzeEpisode] Modüler analiz sırasında hata: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// En güncel haliyle bölümü çekip döndür
|
||||||
|
const finalEpisode = await this.prisma.tubeStrategistEpisode.findUnique({
|
||||||
|
where: { id: episodeId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalEpisode?.masterAnalysis || mergedAnalysis;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -697,6 +715,16 @@ export class TubeStrategistService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteProject(projectId: string, userId: string) {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
|
await this.prisma.tubeStrategistProject.delete({
|
||||||
|
where: { id: project.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
async addDocumentToProject(projectId: string, userId: string, dto: AddDocumentDto) {
|
async addDocumentToProject(projectId: string, userId: string, dto: AddDocumentDto) {
|
||||||
await this.getProjectById(projectId, userId);
|
await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
@@ -833,7 +861,8 @@ export class TubeStrategistService {
|
|||||||
neuroMarketingAnswerDirection: { type: Type.STRING, description: "Neuro marketing esaslarını düşünerek, bu soruya cevabın ne yönde verilmesi ve cevaba nasıl başlanması gerektiğini anlatan stratejik ipuçları." },
|
neuroMarketingAnswerDirection: { type: Type.STRING, description: "Neuro marketing esaslarını düşünerek, bu soruya cevabın ne yönde verilmesi ve cevaba nasıl başlanması gerektiğini anlatan stratejik ipuçları." },
|
||||||
neuroMarketingScore: { type: Type.NUMBER, description: "Bu sorunun kitledeki nöro-pazarlama etki skoru (1-100)" },
|
neuroMarketingScore: { type: Type.NUMBER, description: "Bu sorunun kitledeki nöro-pazarlama etki skoru (1-100)" },
|
||||||
targetArea: { type: Type.STRING, description: "Bu sorunun etkileyeceği nöro-pazarlama alanı (Örn: Korku, Aidiyet, Merak, Güven vb.)" }
|
targetArea: { type: Type.STRING, description: "Bu sorunun etkileyeceği nöro-pazarlama alanı (Örn: Korku, Aidiyet, Merak, Güven vb.)" }
|
||||||
}
|
},
|
||||||
|
required: ['question', 'neuroMarketingAnswerDirection', 'neuroMarketingScore', 'targetArea']
|
||||||
},
|
},
|
||||||
description: "TAM OLARAK 5 adet yeni derin, kışkırtıcı veya sarsıcı soru."
|
description: "TAM OLARAK 5 adet yeni derin, kışkırtıcı veya sarsıcı soru."
|
||||||
}
|
}
|
||||||
@@ -899,7 +928,8 @@ export class TubeStrategistService {
|
|||||||
neuroMarketingAnswerDirection: { type: Type.STRING, description: "Neuro marketing esaslarını düşünerek, bu soruya cevabın ne yönde verilmesi ve cevaba nasıl başlanması gerektiğini anlatan stratejik ipuçları." },
|
neuroMarketingAnswerDirection: { type: Type.STRING, description: "Neuro marketing esaslarını düşünerek, bu soruya cevabın ne yönde verilmesi ve cevaba nasıl başlanması gerektiğini anlatan stratejik ipuçları." },
|
||||||
neuroMarketingScore: { type: Type.NUMBER, description: "Bu sorunun kitledeki nöro-pazarlama etki skoru (1-100)" },
|
neuroMarketingScore: { type: Type.NUMBER, description: "Bu sorunun kitledeki nöro-pazarlama etki skoru (1-100)" },
|
||||||
targetArea: { type: Type.STRING, description: "Bu sorunun etkileyeceği nöro-pazarlama alanı (Örn: Korku, Aidiyet, Merak, Güven vb.)" }
|
targetArea: { type: Type.STRING, description: "Bu sorunun etkileyeceği nöro-pazarlama alanı (Örn: Korku, Aidiyet, Merak, Güven vb.)" }
|
||||||
}
|
},
|
||||||
|
required: ['question', 'neuroMarketingAnswerDirection', 'neuroMarketingScore', 'targetArea']
|
||||||
},
|
},
|
||||||
description: "TAM OLARAK 20 adet derin, kışkırtıcı veya sarsıcı mülakat/röportaj sorusu ve detayları"
|
description: "TAM OLARAK 20 adet derin, kışkırtıcı veya sarsıcı mülakat/röportaj sorusu ve detayları"
|
||||||
}
|
}
|
||||||
@@ -1002,9 +1032,11 @@ export class TubeStrategistService {
|
|||||||
type: Type.OBJECT,
|
type: Type.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
brandName: { type: Type.STRING },
|
brandName: { type: Type.STRING },
|
||||||
integrationStrategy: { type: Type.STRING },
|
reasoning: { type: Type.STRING, description: "Bu marka neden bu içeriğe çok uygun? Kısa bir analiz." },
|
||||||
coldEmailDraft: { type: Type.STRING, description: "Bu markaya gönderilecek işbirliği için Türkçe soğuk e-posta (cold email) taslağı" }
|
integrationIdea: { type: Type.STRING, description: "Videonun içine bu markayı nasıl doğal ve etkili bir şekilde entegre ederiz?" },
|
||||||
}
|
emailDraft: { type: Type.STRING, description: "Bu markaya gönderilecek işbirliği için Türkçe soğuk e-posta (cold email) taslağı" }
|
||||||
|
},
|
||||||
|
required: ['brandName', 'reasoning', 'integrationIdea', 'emailDraft']
|
||||||
},
|
},
|
||||||
description: "Konuyla doğrudan eşleşebilecek TAM OLARAK 10 adet marka ve onlara özel mail taslakları"
|
description: "Konuyla doğrudan eşleşebilecek TAM OLARAK 10 adet marka ve onlara özel mail taslakları"
|
||||||
}
|
}
|
||||||
@@ -1043,20 +1075,207 @@ export class TubeStrategistService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 16:9 = 1920x1080 -> aspectRatio '16:9'
|
// 16:9 = 1920x1080 -> aspectRatio '16:9'
|
||||||
const imageUrl = await this.geminiService.generateImage(prompt, '16:9');
|
const imageResult = await this.geminiService.generateImage(prompt, '16:9');
|
||||||
|
|
||||||
masterAnalysis.thumbnailUrl = imageUrl;
|
if (!imageResult) {
|
||||||
|
throw new Error('Görsel üretilemedi (boş sonuç döndü).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const ext = imageResult.mimeType === 'image/png' ? 'png' : 'jpg';
|
||||||
|
const thumbnailKey = `ts-ep-${episodeId}/thumbnails/thumb-${timestamp}.${ext}`;
|
||||||
|
|
||||||
|
const uploadResult = await this.storageService.upload(
|
||||||
|
thumbnailKey,
|
||||||
|
imageResult.buffer,
|
||||||
|
imageResult.mimeType
|
||||||
|
);
|
||||||
|
|
||||||
|
masterAnalysis.thumbnailUrl = uploadResult.url;
|
||||||
|
|
||||||
await this.prisma.tubeStrategistEpisode.update({
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
where: { id: episodeId },
|
where: { id: episodeId },
|
||||||
data: { masterAnalysis }
|
data: { masterAnalysis }
|
||||||
});
|
});
|
||||||
|
|
||||||
return { thumbnailUrl: imageUrl };
|
return { thumbnailUrl: uploadResult.url };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Thumbnail üretilirken hata oluştu: ${error.message}`);
|
this.logger.error(`Thumbnail üretilirken hata oluştu: ${error.message}`);
|
||||||
throw new Error(`Thumbnail üretilemedi: ${error.message}`);
|
throw new Error(`Thumbnail üretilemedi: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateCommunityIdeas(projectId: string, userId: string) {
|
||||||
|
const project = await this.getProjectById(projectId, userId);
|
||||||
|
|
||||||
|
// Yorumlardan ya da transkriptlerden kitle analizi yapmak için prompt
|
||||||
|
// DB'deki videoların transcriptlerini birleştirelim
|
||||||
|
const transcripts = project.videos.map(v => v.transcript).filter(t => t).join("\n\n").substring(0, 15000);
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
insights: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
topic: { type: Type.STRING },
|
||||||
|
viralityScore: { type: Type.NUMBER },
|
||||||
|
demandReason: { type: Type.STRING },
|
||||||
|
proposedTitle: { type: Type.STRING }
|
||||||
|
},
|
||||||
|
required: ['topic', 'viralityScore', 'demandReason', 'proposedTitle']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['insights']
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Aşağıdaki metinler bir YouTube kanalındaki geçmiş videoların transkript/özetleridir.
|
||||||
|
Kitle dinamiklerini, izleyicinin cevaplanmamış olası sorularını ve "Gizli Taleplerini" analiz et.
|
||||||
|
Bu kitleye sunulabilecek, yüksek virallik (0-100) taşıyan yepyeni GELECEK BÖLÜM (Next Episode) fikirleri üret.
|
||||||
|
|
||||||
|
GEÇMİŞ İÇERİKLER:
|
||||||
|
${transcripts}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-3-pro-preview',
|
||||||
|
contents: prompt,
|
||||||
|
config: { responseMimeType: 'application/json', responseSchema: schema }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const communityInsights = JSON.parse(response.text || '{}');
|
||||||
|
await this.prisma.tubeStrategistProject.update({
|
||||||
|
where: { id: projectId }, data: { communityInsights }
|
||||||
|
});
|
||||||
|
|
||||||
|
return communityInsights;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateEpisodeThumbnailMatrix(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
concepts: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
conceptName: { type: Type.STRING, description: "Örn: Merak Uyandırıcı, Otoriter vs" },
|
||||||
|
title: { type: Type.STRING, description: "YouTube CTR odaklı çarpıcı başlık" },
|
||||||
|
thumbnailDescription: { type: Type.STRING, description: "Görsel kompozisyon, renkler, yüz ifadesi, arka plan" }
|
||||||
|
},
|
||||||
|
required: ['conceptName', 'title', 'thumbnailDescription']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['concepts']
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `YouTube Bölüm Konusu: ${episode.topic}
|
||||||
|
Format: ${episode.format}
|
||||||
|
Hedef Kitle: ${episode.targetAudience}
|
||||||
|
|
||||||
|
Bu bölüm için YouTube CTR (Tıklanma Oranı) odaklı, birbirinden tamamen farklı 3 adet A/B Test konsepti üret. (Örn: Merak Uyandırıcı, Otoriter, Eğlenceli). Her konsept için vurucu bir başlık ve detaylı bir thumbnail görsel açıklaması yaz.`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: { responseMimeType: 'application/json', responseSchema: schema }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const thumbnailMatrix = JSON.parse(response.text || '{}');
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId }, data: { thumbnailMatrix }
|
||||||
|
});
|
||||||
|
|
||||||
|
return thumbnailMatrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateEpisodeShorts(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
shorts: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
conceptTitle: { type: Type.STRING },
|
||||||
|
hook: { type: Type.STRING, description: "İlk 3 saniye kancası" },
|
||||||
|
body: { type: Type.STRING, description: "İçerik özü ve tempo önerileri" },
|
||||||
|
cta: { type: Type.STRING, description: "Harekete geçirici mesaj" }
|
||||||
|
},
|
||||||
|
required: ['conceptTitle', 'hook', 'body', 'cta']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['shorts']
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Uzun format bölüm konusu: ${episode.topic}
|
||||||
|
Bu uzun içeriğin senaryosunu TikTok, Reels ve YouTube Shorts için Gen-Z temposunda parçalara ayır.
|
||||||
|
Toplam 4 adet bağımsız, viral potansiyeli yüksek Shorts / Reels senaryo konsepti üret.`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: { responseMimeType: 'application/json', responseSchema: schema }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const shortsConcepts = JSON.parse(response.text || '{}');
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId }, data: { shortsConcepts }
|
||||||
|
});
|
||||||
|
|
||||||
|
return shortsConcepts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateEpisodeSponsorship(episodeId: string, userId: string) {
|
||||||
|
const episode = await this.getEpisodeById(episodeId, userId);
|
||||||
|
|
||||||
|
const schema: any = {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
pitchDeck: {
|
||||||
|
type: Type.ARRAY,
|
||||||
|
items: {
|
||||||
|
type: Type.OBJECT,
|
||||||
|
properties: {
|
||||||
|
industry: { type: Type.STRING, description: "Örn: VPN, FinTech, Yazılım" },
|
||||||
|
reasoning: { type: Type.STRING, description: "Bu içerik bu sektöre neden uygun?" },
|
||||||
|
emailDraft: { type: Type.STRING, description: "Sponsorluk için markalara atılacak profesyonel, soğuk e-posta (cold email) şablonu" }
|
||||||
|
},
|
||||||
|
required: ['industry', 'reasoning', 'emailDraft']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['pitchDeck']
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = `Bölüm Konusu: ${episode.topic}
|
||||||
|
Hedef Kitle: ${episode.targetAudience}
|
||||||
|
|
||||||
|
Bu videonun hedef kitlesini ve konusunu analiz et. Bu videoya sponsor olabilecek 3 adet jenerik endüstri/sektör (Örn: Finans Uygulaması, Siber Güvenlik Firması vb.) belirle. Her biri için markaların ilgisini çekecek Türkçe bir cold-email (soğuk e-posta) şablonu yaz.`;
|
||||||
|
|
||||||
|
const response = await this.withRetry(() => this.ai.models.generateContent({
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
contents: prompt,
|
||||||
|
config: { responseMimeType: 'application/json', responseSchema: schema }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sponsorshipPitch = JSON.parse(response.text || '{}');
|
||||||
|
await this.prisma.tubeStrategistEpisode.update({
|
||||||
|
where: { id: episodeId }, data: { sponsorshipPitch }
|
||||||
|
});
|
||||||
|
|
||||||
|
return sponsorshipPitch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Post, Put, Body, Get, Param, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
|
import { Controller, Post, Put, Delete, Body, Get, Param, UseGuards, HttpCode, HttpStatus, Req } from '@nestjs/common';
|
||||||
import { YoutubeToolsService } from './youtube-tools.service';
|
import { YoutubeToolsService } from './youtube-tools.service';
|
||||||
import { TubeStrategistService } from './tube-strategist.service';
|
import { TubeStrategistService } from './tube-strategist.service';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
@@ -210,6 +210,12 @@ export class YoutubeToolsController {
|
|||||||
return this.tubeStrategistService.updateProject(id, req.user.id, dto);
|
return this.tubeStrategistService.updateProject(id, req.user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete('strategist/projects/:id')
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Projeyi ve bağlı tüm verileri siler' })
|
||||||
|
async deleteProject(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.deleteProject(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('strategist/projects/:id/document')
|
@Post('strategist/projects/:id/document')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Tube Strategist: Projeye manuel metin dokümanı ekler' })
|
@ApiOperation({ summary: 'Tube Strategist: Projeye manuel metin dokümanı ekler' })
|
||||||
@@ -223,4 +229,32 @@ export class YoutubeToolsController {
|
|||||||
async getTopicSuggestions(@Param('id') id: string, @Req() req: any) {
|
async getTopicSuggestions(@Param('id') id: string, @Req() req: any) {
|
||||||
return this.tubeStrategistService.getTopicSuggestions(id, req.user.id);
|
return this.tubeStrategistService.getTopicSuggestions(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('strategist/projects/:id/community-ideas')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Topluluk Yorum/Transkript analizi ile yeni fikir üretir' })
|
||||||
|
async generateCommunityIdeas(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateCommunityIdeas(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/thumbnail-matrix')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Episode için Thumbnail & Title A/B testi konseptleri üretir' })
|
||||||
|
async generateEpisodeThumbnailMatrix(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateEpisodeThumbnailMatrix(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/shorts-concepts')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Uzun formattan Shorts/Reels fikirleri çıkarır' })
|
||||||
|
async generateEpisodeShorts(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateEpisodeShorts(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('strategist/episodes/:id/sponsorship')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Tube Strategist: Sponsorluk markaları ve e-posta taslakları üretir' })
|
||||||
|
async generateEpisodeSponsorship(@Param('id') id: string, @Req() req: any) {
|
||||||
|
return this.tubeStrategistService.generateEpisodeSponsorship(id, req.user.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const fs = require('fs');
|
||||||
|
async function test() {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('http://localhost:17493/generate', {
|
||||||
|
text: "deneme ses",
|
||||||
|
profile_id: "b8d26247-c8bc-4c06-9364-5b09d11de4bf",
|
||||||
|
language: "en",
|
||||||
|
engine: "kokoro"
|
||||||
|
});
|
||||||
|
console.log('Init:', res.data);
|
||||||
|
const id = res.data.id;
|
||||||
|
let status = res.data.status;
|
||||||
|
while (status !== 'completed') {
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
const hist = await axios.get('http://localhost:17493/history');
|
||||||
|
const item = hist.data.items ? hist.data.items.find(i => i.id === id) : hist.data.find(i => i.id === id);
|
||||||
|
status = item ? item.status : 'failed';
|
||||||
|
console.log('Status:', status);
|
||||||
|
if (status === 'failed') {
|
||||||
|
console.error('Failed!', item.error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status === 'completed') {
|
||||||
|
const audioRes = await axios.get(`http://localhost:17493/audio/${id}`, { responseType: 'arraybuffer' });
|
||||||
|
fs.writeFileSync('/tmp/test-kokoro-direct.wav', audioRes.data);
|
||||||
|
console.log('Audio saved, length:', audioRes.data.length);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error:', e.response?.data || e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test();
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
console.log('Sending POST /generate...');
|
||||||
|
const res = await axios.post('http://localhost:17493/generate', {
|
||||||
|
profile_id: 'b8d26247-c8bc-4c06-9364-5b09d11de4bf',
|
||||||
|
text: 'Hello world again',
|
||||||
|
language: 'en',
|
||||||
|
engine: 'kokoro'
|
||||||
|
});
|
||||||
|
const genId = res.data.id;
|
||||||
|
console.log('Generation ID:', genId);
|
||||||
|
|
||||||
|
console.log('Polling status...');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`http://localhost:17493/generate/${genId}/status`, (response) => {
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
const lines = chunk.toString().split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const data = JSON.parse(line.substring(6));
|
||||||
|
console.log('Status update:', data);
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
console.log('Finished!');
|
||||||
|
resolve(data);
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
console.log('Failed:', data.error);
|
||||||
|
reject(new Error(data.error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test().catch(console.error);
|
||||||
Reference in New Issue
Block a user