generated from fahricansecer/boilerplate-be
@@ -33,6 +33,8 @@ public class ApiSettings
|
|||||||
public string TtsBaseUrl { get; set; } = string.Empty;
|
public string TtsBaseUrl { get; set; } = string.Empty;
|
||||||
public string TtsApiKey { get; set; } = string.Empty;
|
public string TtsApiKey { get; set; } = string.Empty;
|
||||||
public string TtsVoiceId { get; set; } = "pNInz6obpgDQGcFmaJgB";
|
public string TtsVoiceId { get; set; } = "pNInz6obpgDQGcFmaJgB";
|
||||||
|
public string OpenAiApiKey { get; set; } = string.Empty;
|
||||||
|
public string OpenAiTtsVoiceId { get; set; } = "alloy";
|
||||||
public string SunoBaseUrl { get; set; } = string.Empty;
|
public string SunoBaseUrl { get; set; } = string.Empty;
|
||||||
public string SunoApiKey { get; set; } = string.Empty;
|
public string SunoApiKey { get; set; } = string.Empty;
|
||||||
public string CoreApiBaseUrl { get; set; } = "http://localhost:3000/api";
|
public string CoreApiBaseUrl { get; set; } = "http://localhost:3000/api";
|
||||||
|
|||||||
+11
-3
@@ -13,8 +13,9 @@ RUN dotnet publish -c Release -o /app/publish --no-restore
|
|||||||
FROM mcr.microsoft.com/dotnet/runtime:8.0-alpine AS runtime
|
FROM mcr.microsoft.com/dotnet/runtime:8.0-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# FFmpeg ve Globalization kurulumu (ARM64 native Alpine paketi)
|
# FFmpeg, Node.js (Remotion için) ve Chromium (Puppeteer/Remotion için) kurulumu
|
||||||
RUN apk add --no-cache ffmpeg font-dejavu icu-libs
|
RUN apk add --no-cache ffmpeg font-dejavu icu-libs nodejs npm chromium nss freetype harfbuzz ttf-freefont
|
||||||
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
|
|
||||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||||
|
|
||||||
@@ -23,8 +24,15 @@ RUN mkdir -p /tmp/contgen-render
|
|||||||
|
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Remotion projesini kopyala ve bağımlılıkları kur
|
||||||
|
COPY remotion ./remotion
|
||||||
|
RUN cd remotion && npm ci
|
||||||
|
|
||||||
# Non-root user ile çalıştır (güvenlik)
|
# Non-root user ile çalıştır (güvenlik)
|
||||||
RUN adduser -D -h /app workeruser
|
RUN adduser -D -h /app workeruser && \
|
||||||
|
chown -R workeruser:workeruser /app/remotion && \
|
||||||
|
chown -R workeruser:workeruser /tmp/contgen-render
|
||||||
|
|
||||||
USER workeruser
|
USER workeruser
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "SaasMediaWorker.dll"]
|
ENTRYPOINT ["dotnet", "SaasMediaWorker.dll"]
|
||||||
|
|||||||
@@ -145,6 +145,12 @@ public class ScenePayload
|
|||||||
|
|
||||||
[JsonPropertyName("ambientSoundPrompt")]
|
[JsonPropertyName("ambientSoundPrompt")]
|
||||||
public string? AmbientSoundPrompt { get; set; }
|
public string? AmbientSoundPrompt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("imagePath")]
|
||||||
|
public string? ImagePath { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("ttsProvider")]
|
||||||
|
public string? TtsProvider { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -67,15 +67,22 @@ try
|
|||||||
builder.Services.AddHttpClient<TtsService>("TTS")
|
builder.Services.AddHttpClient<TtsService>("TTS")
|
||||||
.AddPolicyHandler(combinedPolicy);
|
.AddPolicyHandler(combinedPolicy);
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<OpenAiTtsService>("OpenAITTS")
|
||||||
|
.AddPolicyHandler(combinedPolicy);
|
||||||
|
|
||||||
builder.Services.AddHttpClient<SunoMusicService>("Suno")
|
builder.Services.AddHttpClient<SunoMusicService>("Suno")
|
||||||
.AddPolicyHandler(combinedPolicy);
|
.AddPolicyHandler(combinedPolicy);
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<AudioCraftService>("AudioCraft")
|
||||||
|
.AddPolicyHandler(combinedPolicy);
|
||||||
|
|
||||||
builder.Services.AddHttpClient<ApiNotificationService>("CoreAPI")
|
builder.Services.AddHttpClient<ApiNotificationService>("CoreAPI")
|
||||||
.AddPolicyHandler(retryPolicy);
|
.AddPolicyHandler(retryPolicy);
|
||||||
|
|
||||||
// Service registrations
|
// Service registrations
|
||||||
builder.Services.AddSingleton<S3StorageService>();
|
builder.Services.AddSingleton<S3StorageService>();
|
||||||
builder.Services.AddSingleton<FFmpegService>();
|
builder.Services.AddSingleton<FFmpegService>();
|
||||||
|
builder.Services.AddSingleton<RemotionService>();
|
||||||
builder.Services.AddSingleton<VideoRenderPipeline>();
|
builder.Services.AddSingleton<VideoRenderPipeline>();
|
||||||
builder.Services.AddSingleton<DatabaseService>();
|
builder.Services.AddSingleton<DatabaseService>();
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,10 @@ public class DatabaseService
|
|||||||
var sql = @"
|
var sql = @"
|
||||||
UPDATE ""RenderJob""
|
UPDATE ""RenderJob""
|
||||||
SET ""status"" = @status::""RenderJobStatus"",
|
SET ""status"" = @status::""RenderJobStatus"",
|
||||||
""progress"" = @progress,
|
|
||||||
""currentStage"" = CASE WHEN @stage IS NOT NULL THEN @stage::""RenderStage"" ELSE ""currentStage"" END,
|
""currentStage"" = CASE WHEN @stage IS NOT NULL THEN @stage::""RenderStage"" ELSE ""currentStage"" END,
|
||||||
""errorMessage"" = COALESCE(@errorMessage, ""errorMessage""),
|
""errorMessage"" = COALESCE(@errorMessage, ""errorMessage""),
|
||||||
""errorStack"" = COALESCE(@errorStack, ""errorStack""),
|
|
||||||
""processingTimeMs"" = COALESCE(@processingTimeMs, ""processingTimeMs""),
|
""processingTimeMs"" = COALESCE(@processingTimeMs, ""processingTimeMs""),
|
||||||
""workerVersion"" = COALESCE(@workerVersion, ""workerVersion""),
|
|
||||||
""workerHostname"" = COALESCE(@workerHostname, ""workerHostname""),
|
""workerHostname"" = COALESCE(@workerHostname, ""workerHostname""),
|
||||||
""startedAt"" = CASE WHEN @status = 'PROCESSING' AND ""startedAt"" IS NULL THEN NOW() ELSE ""startedAt"" END,
|
|
||||||
""completedAt"" = CASE WHEN @status IN ('COMPLETED', 'FAILED') THEN NOW() ELSE ""completedAt"" END,
|
|
||||||
""lastErrorAt"" = CASE WHEN @status = 'FAILED' THEN NOW() ELSE ""lastErrorAt"" END,
|
|
||||||
""updatedAt"" = NOW()
|
""updatedAt"" = NOW()
|
||||||
WHERE ""id"" = @id";
|
WHERE ""id"" = @id";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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>
|
||||||
|
/// OpenAI TTS API Client — Metin → Ses dönüşümü.
|
||||||
|
/// </summary>
|
||||||
|
public class OpenAiTtsService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<OpenAiTtsService> _logger;
|
||||||
|
private readonly ApiSettings _settings;
|
||||||
|
|
||||||
|
public OpenAiTtsService(
|
||||||
|
HttpClient httpClient,
|
||||||
|
ILogger<OpenAiTtsService> logger,
|
||||||
|
IOptions<ApiSettings> settings)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_logger = logger;
|
||||||
|
_settings = settings.Value;
|
||||||
|
|
||||||
|
_httpClient.BaseAddress = new Uri("https://api.openai.com/v1/");
|
||||||
|
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.OpenAiApiKey);
|
||||||
|
_httpClient.Timeout = TimeSpan.FromMinutes(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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(
|
||||||
|
"🎙️ OpenAI TTS üretimi — Sahne {Order}: \"{Text}\"",
|
||||||
|
scene.Order,
|
||||||
|
scene.NarrationText[..Math.Min(60, scene.NarrationText.Length)]);
|
||||||
|
|
||||||
|
// Varsayılan voiceStyle kullan veya fallback
|
||||||
|
var voiceId = string.IsNullOrWhiteSpace(voiceStyle) ? _settings.OpenAiTtsVoiceId : voiceStyle;
|
||||||
|
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
model = "tts-1",
|
||||||
|
input = scene.NarrationText,
|
||||||
|
voice = voiceId,
|
||||||
|
response_format = "mp3"
|
||||||
|
};
|
||||||
|
|
||||||
|
var content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(requestBody),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json");
|
||||||
|
|
||||||
|
var response = await _httpClient.PostAsync("audio/speech", content, ct);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// Ses dosyasını kaydet
|
||||||
|
var outputPath = Path.Combine(outputDirectory, $"scene_{scene.Order:D2}_narration.mp3");
|
||||||
|
await using var fileStream = File.Create(outputPath);
|
||||||
|
await response.Content.CopyToAsync(fileStream, ct);
|
||||||
|
|
||||||
|
var fileInfo = new FileInfo(outputPath);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"OpenAI 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/mpeg",
|
||||||
|
AiProvider = "openai"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SaasMediaWorker.Models;
|
||||||
|
|
||||||
|
namespace SaasMediaWorker.Services;
|
||||||
|
|
||||||
|
public class RemotionService
|
||||||
|
{
|
||||||
|
private readonly ILogger<RemotionService> _logger;
|
||||||
|
|
||||||
|
public RemotionService(ILogger<RemotionService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> RenderVideoAsync(
|
||||||
|
string projectId,
|
||||||
|
string projectDir,
|
||||||
|
List<ScenePayload> scenes,
|
||||||
|
List<GeneratedMediaFile> generatedMedia,
|
||||||
|
string? musicPath,
|
||||||
|
int targetDurationSeconds,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🎬 Remotion render başlatılıyor — Project: {Id}", projectId);
|
||||||
|
|
||||||
|
// Remotion projesinin kök dizini (media-worker içindeki remotion klasörü)
|
||||||
|
var remotionDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "remotion");
|
||||||
|
|
||||||
|
// Props JSON dosyasını hazırla
|
||||||
|
var props = new
|
||||||
|
{
|
||||||
|
musicPath = musicPath,
|
||||||
|
scenes = scenes.Select(s => new
|
||||||
|
{
|
||||||
|
imagePath = s.ImagePath,
|
||||||
|
audioPath = generatedMedia.FirstOrDefault(m => m.SceneOrder == s.Order && m.Type == MediaFileType.AudioNarration)?.LocalPath,
|
||||||
|
ambientPath = generatedMedia.FirstOrDefault(m => m.SceneOrder == s.Order && m.Type == MediaFileType.AudioAmbient)?.LocalPath,
|
||||||
|
subtitle = s.SubtitleText,
|
||||||
|
durationInFrames = (int)(s.Duration * 30) // 30 FPS varsayımı
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var propsPath = Path.Combine(projectDir, "remotion-props.json");
|
||||||
|
await File.WriteAllTextAsync(propsPath, JsonSerializer.Serialize(props), ct);
|
||||||
|
|
||||||
|
// Final çıktı yolu
|
||||||
|
var outputPath = Path.Combine(projectDir, $"final_{projectId}.mp4");
|
||||||
|
|
||||||
|
// npx remotion render src/index.ts MainVideo output.mp4 --props props.json
|
||||||
|
var arguments = $"remotion render src/index.ts MainVideo \"{outputPath}\" --props=\"{propsPath}\"";
|
||||||
|
|
||||||
|
_logger.LogInformation("Çalıştırılıyor: npx {Args} (Dizin: {Dir})", arguments, remotionDir);
|
||||||
|
|
||||||
|
var processInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "npx",
|
||||||
|
Arguments = arguments,
|
||||||
|
WorkingDirectory = remotionDir,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = Process.Start(processInfo);
|
||||||
|
if (process == null)
|
||||||
|
throw new InvalidOperationException("Remotion process başlatılamadı.");
|
||||||
|
|
||||||
|
var outputTask = process.StandardOutput.ReadToEndAsync();
|
||||||
|
var errorTask = process.StandardError.ReadToEndAsync();
|
||||||
|
|
||||||
|
await process.WaitForExitAsync(ct);
|
||||||
|
|
||||||
|
var output = await outputTask;
|
||||||
|
var error = await errorTask;
|
||||||
|
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
_logger.LogError("Remotion render hatası. ExitCode: {Code}\nOutput: {Output}\nError: {Error}",
|
||||||
|
process.ExitCode, output, error);
|
||||||
|
throw new Exception($"Remotion render başarısız oldu. Hata: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ Remotion render tamamlandı: {Path}", outputPath);
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,10 @@ public class VideoRenderPipeline
|
|||||||
private readonly ILogger<VideoRenderPipeline> _logger;
|
private readonly ILogger<VideoRenderPipeline> _logger;
|
||||||
private readonly HiggsFieldService _higgsField;
|
private readonly HiggsFieldService _higgsField;
|
||||||
private readonly TtsService _tts;
|
private readonly TtsService _tts;
|
||||||
|
private readonly OpenAiTtsService _openAiTts;
|
||||||
private readonly SunoMusicService _sunoMusic;
|
private readonly SunoMusicService _sunoMusic;
|
||||||
private readonly AudioCraftService _audioCraft;
|
private readonly AudioCraftService _audioCraft;
|
||||||
|
private readonly RemotionService _remotion;
|
||||||
private readonly FFmpegService _ffmpeg;
|
private readonly FFmpegService _ffmpeg;
|
||||||
private readonly S3StorageService _s3;
|
private readonly S3StorageService _s3;
|
||||||
private readonly FFmpegSettings _ffmpegSettings;
|
private readonly FFmpegSettings _ffmpegSettings;
|
||||||
@@ -33,8 +35,10 @@ public class VideoRenderPipeline
|
|||||||
ILogger<VideoRenderPipeline> logger,
|
ILogger<VideoRenderPipeline> logger,
|
||||||
HiggsFieldService higgsField,
|
HiggsFieldService higgsField,
|
||||||
TtsService tts,
|
TtsService tts,
|
||||||
|
OpenAiTtsService openAiTts,
|
||||||
SunoMusicService sunoMusic,
|
SunoMusicService sunoMusic,
|
||||||
AudioCraftService audioCraft,
|
AudioCraftService audioCraft,
|
||||||
|
RemotionService remotion,
|
||||||
FFmpegService ffmpeg,
|
FFmpegService ffmpeg,
|
||||||
S3StorageService s3,
|
S3StorageService s3,
|
||||||
IOptions<FFmpegSettings> ffmpegSettings)
|
IOptions<FFmpegSettings> ffmpegSettings)
|
||||||
@@ -42,8 +46,10 @@ public class VideoRenderPipeline
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_higgsField = higgsField;
|
_higgsField = higgsField;
|
||||||
_tts = tts;
|
_tts = tts;
|
||||||
|
_openAiTts = openAiTts;
|
||||||
_sunoMusic = sunoMusic;
|
_sunoMusic = sunoMusic;
|
||||||
_audioCraft = audioCraft;
|
_audioCraft = audioCraft;
|
||||||
|
_remotion = remotion;
|
||||||
_ffmpeg = ffmpeg;
|
_ffmpeg = ffmpeg;
|
||||||
_s3 = s3;
|
_s3 = s3;
|
||||||
_ffmpegSettings = ffmpegSettings.Value;
|
_ffmpegSettings = ffmpegSettings.Value;
|
||||||
@@ -67,30 +73,13 @@ public class VideoRenderPipeline
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var scenes = job.Scenes.OrderBy(s => s.Order).ToList();
|
var scenes = job.Scenes.OrderBy(s => s.Order).ToList();
|
||||||
var totalSteps = scenes.Count * 3 + 4; // (video+tts+ambient per scene) + music + merge + upload + finalize
|
var totalSteps = scenes.Count * 2 + 4; // (tts+ambient per scene) + music + remotion + upload + finalize
|
||||||
var completedSteps = 0;
|
var completedSteps = 0;
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
// ADIM 1: Her sahne için video klip üret
|
// ADIM 1: Video Klip Üretimi (ATLANDI - REMOTION YAPACAK)
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
_logger.LogInformation(
|
_logger.LogInformation("📹 Adım 1: Video üretimi atlandı, görseller doğrudan Remotion'da Ken Burns ile işlenecek.");
|
||||||
"📹 Adım 1/5: Video klip üretimi — {Count} sahne", scenes.Count);
|
|
||||||
|
|
||||||
var videoTasks = new List<Task<GeneratedMediaFile>>();
|
|
||||||
foreach (var scene in scenes)
|
|
||||||
{
|
|
||||||
videoTasks.Add(GenerateVideoClipWithProgress(
|
|
||||||
scene, projectDir, job.AspectRatio,
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
completedSteps++;
|
|
||||||
var progress = (int)(completedSteps / (double)totalSteps * 60); // Video %0-60
|
|
||||||
return progressCallback(Math.Min(progress, 60), "VIDEO_GENERATION");
|
|
||||||
}, ct));
|
|
||||||
}
|
|
||||||
|
|
||||||
var videoResults = await Task.WhenAll(videoTasks);
|
|
||||||
allMediaFiles.AddRange(videoResults);
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
// ADIM 2: Her sahne için TTS narration üret
|
// ADIM 2: Her sahne için TTS narration üret
|
||||||
@@ -107,8 +96,8 @@ public class VideoRenderPipeline
|
|||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
completedSteps++;
|
completedSteps++;
|
||||||
var progress = 60 + (int)(completedSteps / (double)totalSteps * 10); // TTS %60-70
|
var progress = (int)(completedSteps / (double)totalSteps * 40); // TTS %0-40
|
||||||
return progressCallback(Math.Min(progress, 70), "TTS_GENERATION");
|
return progressCallback(progress, "TTS_GENERATION");
|
||||||
}, ct));
|
}, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +108,7 @@ public class VideoRenderPipeline
|
|||||||
// ADIM 3: Background müzik üret
|
// ADIM 3: Background müzik üret
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
_logger.LogInformation("🎵 Adım 3/6: Background müzik üretimi (AudioCraft MusicGen)");
|
_logger.LogInformation("🎵 Adım 3/6: Background müzik üretimi (AudioCraft MusicGen)");
|
||||||
await progressCallback(72, "MUSIC_GENERATION");
|
await progressCallback(45, "MUSIC_GENERATION");
|
||||||
|
|
||||||
var musicPrompt = job.ScriptJson?.MusicPrompt
|
var musicPrompt = job.ScriptJson?.MusicPrompt
|
||||||
?? "Cinematic orchestral, mysterious, slow build, 80 BPM, strings and piano";
|
?? "Cinematic orchestral, mysterious, slow build, 80 BPM, strings and piano";
|
||||||
@@ -164,13 +153,13 @@ public class VideoRenderPipeline
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await progressCallback(78, "MUSIC_GENERATION");
|
await progressCallback(55, "MUSIC_GENERATION");
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
// ADIM 4: AudioGen — Sahne bazlı ambient sesler
|
// ADIM 4: AudioGen — Sahne bazlı ambient sesler
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
_logger.LogInformation("🔊 Adım 4/6: AudioGen ambient ses efektleri");
|
_logger.LogInformation("🔊 Adım 4/6: AudioGen ambient ses efektleri");
|
||||||
await progressCallback(79, "AMBIENT_GENERATION");
|
await progressCallback(60, "AMBIENT_GENERATION");
|
||||||
|
|
||||||
var ambientFiles = new List<GeneratedMediaFile>();
|
var ambientFiles = new List<GeneratedMediaFile>();
|
||||||
try
|
try
|
||||||
@@ -184,67 +173,16 @@ public class VideoRenderPipeline
|
|||||||
_logger.LogWarning(ex, "Ambient ses üretimi başarısız — ambientsiz devam ediliyor");
|
_logger.LogWarning(ex, "Ambient ses üretimi başarısız — ambientsiz devam ediliyor");
|
||||||
}
|
}
|
||||||
|
|
||||||
await progressCallback(82, "AMBIENT_GENERATION");
|
await progressCallback(70, "AMBIENT_GENERATION");
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
// ADIM 5: FFmpeg — Birleştirme, ambient overlay ve altyazı
|
// ADIM 5: REMOTION — Video render (Ken Burns + Audio Merge + Subtitles)
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
_logger.LogInformation("🎬 Adım 5/6: FFmpeg render — merge + ambient + subtitle + finalize");
|
_logger.LogInformation("🎬 Adım 5/6: Remotion render — Ken Burns + audio merge + subtitle");
|
||||||
await progressCallback(83, "MEDIA_MERGE");
|
await progressCallback(75, "MEDIA_MERGE");
|
||||||
|
|
||||||
var mergedScenePaths = new List<string>();
|
var finalLocalPath = await _remotion.RenderVideoAsync(
|
||||||
|
job.ProjectId, projectDir, scenes, allMediaFiles, musicFile?.LocalPath, job.TargetDuration, ct);
|
||||||
foreach (var scene in scenes)
|
|
||||||
{
|
|
||||||
var videoFile = videoResults.FirstOrDefault(v =>
|
|
||||||
v.SceneOrder == scene.Order && v.Type == MediaFileType.VideoClip);
|
|
||||||
var ttsFile = ttsResults.FirstOrDefault(t =>
|
|
||||||
t.SceneOrder == scene.Order && t.Type == MediaFileType.AudioNarration);
|
|
||||||
|
|
||||||
if (videoFile == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Sahne {Order} için video bulunamadı, atlanıyor", scene.Order);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentPath = videoFile.LocalPath;
|
|
||||||
|
|
||||||
// Video + Narration merge
|
|
||||||
if (ttsFile != null)
|
|
||||||
{
|
|
||||||
currentPath = await _ffmpeg.MergeVideoWithNarrationAsync(
|
|
||||||
currentPath, ttsFile.LocalPath, scene.Order, projectDir, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ambient ses overlay (AudioGen)
|
|
||||||
var ambientFile = ambientFiles.FirstOrDefault(a =>
|
|
||||||
a.SceneOrder == scene.Order && a.Type == MediaFileType.AudioAmbient);
|
|
||||||
if (ambientFile != null)
|
|
||||||
{
|
|
||||||
currentPath = await _ffmpeg.OverlayAmbientAudioAsync(
|
|
||||||
currentPath, ambientFile.LocalPath, scene.Order, projectDir, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Altyazı ekle
|
|
||||||
if (!string.IsNullOrEmpty(scene.SubtitleText))
|
|
||||||
{
|
|
||||||
currentPath = await _ffmpeg.AddSubtitlesAsync(
|
|
||||||
currentPath, scene.SubtitleText, scene.Order, projectDir, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
mergedScenePaths.Add(currentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
await progressCallback(88, "MEDIA_MERGE");
|
|
||||||
|
|
||||||
// Final concatenation + music mix
|
|
||||||
var finalLocalPath = await _ffmpeg.ConcatenateAndFinalize(
|
|
||||||
mergedScenePaths,
|
|
||||||
musicFile?.LocalPath,
|
|
||||||
projectDir,
|
|
||||||
job.ProjectId,
|
|
||||||
job.TargetDuration,
|
|
||||||
ct);
|
|
||||||
|
|
||||||
allMediaFiles.Add(new GeneratedMediaFile
|
allMediaFiles.Add(new GeneratedMediaFile
|
||||||
{
|
{
|
||||||
@@ -255,7 +193,7 @@ public class VideoRenderPipeline
|
|||||||
MimeType = "video/mp4"
|
MimeType = "video/mp4"
|
||||||
});
|
});
|
||||||
|
|
||||||
await progressCallback(92, "MEDIA_MERGE");
|
await progressCallback(90, "MEDIA_MERGE");
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
// ADIM 6: S3'e yükle
|
// ADIM 6: S3'e yükle
|
||||||
@@ -285,22 +223,23 @@ public class VideoRenderPipeline
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<GeneratedMediaFile> GenerateVideoClipWithProgress(
|
|
||||||
ScenePayload scene, string outputDir, string aspectRatio,
|
|
||||||
Func<Task> onComplete, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await _higgsField.GenerateVideoClipAsync(
|
|
||||||
scene, outputDir, aspectRatio, ct);
|
|
||||||
await onComplete();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<GeneratedMediaFile> GenerateTtsWithProgress(
|
private async Task<GeneratedMediaFile> GenerateTtsWithProgress(
|
||||||
ScenePayload scene, string outputDir, string voiceStyle,
|
ScenePayload scene, string outputDir, string voiceStyle,
|
||||||
Func<Task> onComplete, CancellationToken ct)
|
Func<Task> onComplete, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await _tts.GenerateNarrationAsync(
|
GeneratedMediaFile result;
|
||||||
scene, outputDir, voiceStyle, ct);
|
|
||||||
|
if (!string.IsNullOrEmpty(scene.TtsProvider) && scene.TtsProvider.Equals("openai", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
result = await _openAiTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Default: ElevenLabs
|
||||||
|
result = await _tts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct);
|
||||||
|
}
|
||||||
|
|
||||||
await onComplete();
|
await onComplete();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Ignore the output video from Git but not videos you import into src/.
|
||||||
|
out
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Remotion video
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/remotion-dev/logo">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-dark.apng">
|
||||||
|
<img alt="Animated Remotion Logo" src="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-light.gif">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Welcome to your Remotion project!
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
**Install Dependencies**
|
||||||
|
|
||||||
|
```console
|
||||||
|
npm i
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start Preview**
|
||||||
|
|
||||||
|
```console
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Render video**
|
||||||
|
|
||||||
|
```console
|
||||||
|
npx remotion render
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upgrade Remotion**
|
||||||
|
|
||||||
|
```console
|
||||||
|
npx remotion upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals).
|
||||||
|
|
||||||
|
## Help
|
||||||
|
|
||||||
|
We provide help on our [Discord server](https://discord.gg/6VzzNDwUwV).
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
Found an issue with Remotion? [File an issue here](https://github.com/remotion-dev/remotion/issues/new).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { config } from "@remotion/eslint-config-flat";
|
||||||
|
|
||||||
|
export default config;
|
||||||
Generated
+4920
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "remotion",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "My Remotion video",
|
||||||
|
"repository": {},
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@remotion/cli": "4.0.451",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"remotion": "4.0.451",
|
||||||
|
"@remotion/tailwind-v4": "4.0.451",
|
||||||
|
"tailwindcss": "4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@remotion/eslint-config-flat": "4.0.451",
|
||||||
|
"@types/react": "19.2.7",
|
||||||
|
"@types/web": "0.0.166",
|
||||||
|
"eslint": "9.19.0",
|
||||||
|
"prettier": "3.8.1",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "remotion studio",
|
||||||
|
"build": "remotion bundle",
|
||||||
|
"upgrade": "remotion upgrade",
|
||||||
|
"lint": "eslint src && tsc"
|
||||||
|
},
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Note: When using the Node.JS APIs, the config file
|
||||||
|
* doesn't apply. Instead, pass options directly to the APIs.
|
||||||
|
*
|
||||||
|
* All configuration options: https://remotion.dev/docs/config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Config } from "@remotion/cli/config";
|
||||||
|
import { enableTailwind } from '@remotion/tailwind-v4';
|
||||||
|
|
||||||
|
Config.setVideoImageFormat("jpeg");
|
||||||
|
Config.setOverwriteOutput(true);
|
||||||
|
Config.overrideWebpackConfig(enableTailwind);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const MyComposition = () => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
AbsoluteFill,
|
||||||
|
Audio,
|
||||||
|
Img,
|
||||||
|
Sequence,
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
} from "remotion";
|
||||||
|
|
||||||
|
export type SceneProp = {
|
||||||
|
imagePath: string; // absolute path inside docker /data/media/..
|
||||||
|
audioPath?: string;
|
||||||
|
ambientPath?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
durationInFrames: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MainVideoProps = {
|
||||||
|
scenes: SceneProp[];
|
||||||
|
musicPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MainVideo: React.FC<MainVideoProps> = ({ scenes, musicPath }) => {
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
// Helper: map local absolute path to standard file:// URL so Remotion Chromium can load it
|
||||||
|
const makeFileUrl = (path?: string) => {
|
||||||
|
if (!path) return undefined;
|
||||||
|
if (path.startsWith("http")) return path;
|
||||||
|
if (path.startsWith("file://")) return path;
|
||||||
|
return `file://${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentStartFrame = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||||
|
{/* Background Music */}
|
||||||
|
{musicPath && <Audio src={makeFileUrl(musicPath)} volume={0.15} />}
|
||||||
|
|
||||||
|
{scenes.map((scene, index) => {
|
||||||
|
const startFrame = currentStartFrame;
|
||||||
|
currentStartFrame += scene.durationInFrames;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sequence
|
||||||
|
key={index}
|
||||||
|
from={startFrame}
|
||||||
|
durationInFrames={scene.durationInFrames}
|
||||||
|
>
|
||||||
|
<SceneRenderer scene={scene} index={index} />
|
||||||
|
</Sequence>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SceneRenderer: React.FC<{ scene: SceneProp; index: number }> = ({ scene, index }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
// Ken Burns Effect (Zoom In / Pan)
|
||||||
|
// Even scenes zoom in, odd scenes zoom out slightly for variation
|
||||||
|
const scale = interpolate(
|
||||||
|
frame,
|
||||||
|
[0, scene.durationInFrames],
|
||||||
|
index % 2 === 0 ? [1, 1.1] : [1.1, 1],
|
||||||
|
{ extrapolateRight: "clamp" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeFileUrl = (path?: string) => {
|
||||||
|
if (!path) return undefined;
|
||||||
|
if (path.startsWith("http")) return path;
|
||||||
|
if (path.startsWith("file://")) return path;
|
||||||
|
return `file://${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ overflow: "hidden" }}>
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scene.imagePath ? (
|
||||||
|
<Img
|
||||||
|
src={makeFileUrl(scene.imagePath)}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "#111", justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<div style={{ color: "rgba(255, 255, 255, 0.2)", fontSize: "80px", fontFamily: "sans-serif", textAlign: "center", padding: "40px" }}>
|
||||||
|
Visual Not Generated
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
)}
|
||||||
|
</AbsoluteFill>
|
||||||
|
|
||||||
|
{/* Audio Tracks */}
|
||||||
|
{scene.audioPath && <Audio src={makeFileUrl(scene.audioPath)} />}
|
||||||
|
{scene.ambientPath && <Audio src={makeFileUrl(scene.ambientPath)} volume={0.3} />}
|
||||||
|
|
||||||
|
{/* Subtitles Overlay */}
|
||||||
|
{scene.subtitle && (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: "80px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
color: "white",
|
||||||
|
padding: "15px 30px",
|
||||||
|
borderRadius: "15px",
|
||||||
|
fontSize: "48px",
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: "80%",
|
||||||
|
boxShadow: "0 4px 6px rgba(0,0,0,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scene.subtitle}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
)}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "./index.css";
|
||||||
|
import { Composition } from "remotion";
|
||||||
|
import { MainVideo } from "./MainVideo";
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Composition
|
||||||
|
id="MainVideo"
|
||||||
|
component={MainVideo}
|
||||||
|
durationInFrames={300} // Default value, will be overridden via props
|
||||||
|
fps={30}
|
||||||
|
width={1080}
|
||||||
|
height={1920} // Portrait by default (9:16)
|
||||||
|
defaultProps={{
|
||||||
|
scenes: [],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRoot } from "remotion";
|
||||||
|
import { RemotionRoot } from "./Root";
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2018",
|
||||||
|
"module": "commonjs",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["es2015"],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noUnusedLocals": true
|
||||||
|
},
|
||||||
|
"exclude": ["remotion.config.ts"]
|
||||||
|
}
|
||||||
+5
-2
@@ -3,8 +3,11 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": false,
|
||||||
"assets": ["i18n/**/*"],
|
"assets": [
|
||||||
|
"**/*.json",
|
||||||
|
"i18n/**/*"
|
||||||
|
],
|
||||||
"watchAssets": true
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -38,7 +38,6 @@
|
|||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/sharp": "^0.32.0",
|
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.66.4",
|
"bullmq": "^5.66.4",
|
||||||
@@ -79,6 +78,7 @@
|
|||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/nodemailer": "^7.0.4",
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/sharp": "^0.32.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
const file = '/Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/src/modules/gemini/gemini.service.ts';
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
// Update tryGenerateContentImage signature to return finishReason
|
||||||
|
content = content.replace(
|
||||||
|
/Promise<\{ buffer: Buffer; mimeType: string \} \| null>/g,
|
||||||
|
'Promise<{ buffer: Buffer; mimeType: string; finishReason?: string } | null>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update tryGenerateContentImage return
|
||||||
|
content = content.replace(
|
||||||
|
/return null;\s*}\s*const imagePart/g,
|
||||||
|
'return { buffer: Buffer.from([]), mimeType: "", finishReason };\n }\n\n const imagePart'
|
||||||
|
);
|
||||||
|
|
||||||
|
// In tryGenerateContentImage, we need to return the finish reason when buffer is empty so we know it's a safety block.
|
||||||
|
// Let's just use replace_file_content tool, it's safer than regex.
|
||||||
Generated
+3
-3
@@ -56,9 +56,6 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0(prisma@5.22.0)
|
version: 5.22.0(prisma@5.22.0)
|
||||||
'@types/sharp':
|
|
||||||
specifier: ^0.32.0
|
|
||||||
version: 0.32.0
|
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.15.0
|
specifier: ^1.15.0
|
||||||
version: 1.15.0
|
version: 1.15.0
|
||||||
@@ -174,6 +171,9 @@ importers:
|
|||||||
'@types/passport-jwt':
|
'@types/passport-jwt':
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
|
'@types/sharp':
|
||||||
|
specifier: ^0.32.0
|
||||||
|
version: 0.32.0
|
||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
|||||||
@@ -253,9 +253,14 @@ export class AuthService {
|
|||||||
tenantId: user.tenantId || undefined,
|
tenantId: user.tenantId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAdmin = roles.includes('admin');
|
||||||
|
const accessExpiration = isAdmin
|
||||||
|
? '7d'
|
||||||
|
: this.configService.get('JWT_ACCESS_EXPIRATION', '15m');
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = this.jwtService.sign(payload, {
|
const accessToken = this.jwtService.sign(payload, {
|
||||||
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
expiresIn: accessExpiration as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
@@ -276,10 +281,7 @@ export class AuthService {
|
|||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken: refreshTokenValue,
|
refreshToken: refreshTokenValue,
|
||||||
expiresIn:
|
expiresIn: this.parseExpiration(accessExpiration) / 1000, // Convert to seconds
|
||||||
this.parseExpiration(
|
|
||||||
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
|
||||||
) / 1000, // Convert to seconds
|
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@@ -279,11 +279,19 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
|||||||
try {
|
try {
|
||||||
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
|
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
|
||||||
const result = await this.tryGenerateContentImage(primaryModel, enhancedPrompt);
|
const result = await this.tryGenerateContentImage(primaryModel, enhancedPrompt);
|
||||||
if (result) {
|
if (result && result.buffer.length > 0) {
|
||||||
this.logger.log(`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
|
this.logger.log(`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
|
||||||
return result;
|
return { buffer: result.buffer, mimeType: result.mimeType };
|
||||||
}
|
}
|
||||||
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (null response)`);
|
|
||||||
|
const reason = result?.errorReason || 'null response';
|
||||||
|
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`);
|
||||||
|
|
||||||
|
if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)) {
|
||||||
|
this.logger.warn(`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`);
|
||||||
|
break; // Fail fast for safety blocks
|
||||||
|
}
|
||||||
|
|
||||||
if (attempt < 2) await this.sleep(2000);
|
if (attempt < 2) await this.sleep(2000);
|
||||||
} catch (err1: any) {
|
} catch (err1: any) {
|
||||||
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`);
|
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`);
|
||||||
@@ -295,11 +303,15 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
|||||||
try {
|
try {
|
||||||
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
|
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
|
||||||
const result = await this.tryGenerateContentImage(fallbackModel, enhancedPrompt);
|
const result = await this.tryGenerateContentImage(fallbackModel, enhancedPrompt);
|
||||||
if (result) {
|
if (result && result.buffer.length > 0) {
|
||||||
this.logger.log(`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
|
this.logger.log(`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
|
||||||
return result;
|
return { buffer: result.buffer, mimeType: result.mimeType };
|
||||||
|
}
|
||||||
|
this.logger.warn(`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`);
|
||||||
|
|
||||||
|
if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(result?.errorReason || '')) {
|
||||||
|
this.logger.warn(`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`);
|
||||||
}
|
}
|
||||||
this.logger.warn(`⚠️ ${fallbackModel}: görsel döndürmedi (null response)`);
|
|
||||||
} catch (err2: any) {
|
} catch (err2: any) {
|
||||||
this.logger.warn(`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`);
|
this.logger.warn(`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`);
|
||||||
}
|
}
|
||||||
@@ -323,7 +335,7 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
|||||||
this.logger.log(`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`);
|
this.logger.log(`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`);
|
||||||
return { buffer, mimeType };
|
return { buffer, mimeType };
|
||||||
}
|
}
|
||||||
this.logger.warn('⚠️ Imagen 4: görsel döndürmedi');
|
this.logger.warn(`⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`);
|
||||||
} catch (err3: any) {
|
} catch (err3: any) {
|
||||||
this.logger.warn(`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`);
|
this.logger.warn(`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`);
|
||||||
}
|
}
|
||||||
@@ -343,7 +355,7 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
|||||||
private async tryGenerateContentImage(
|
private async tryGenerateContentImage(
|
||||||
model: string,
|
model: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<{ buffer: Buffer; mimeType: string } | null> {
|
): Promise<{ buffer: Buffer; mimeType: string; errorReason?: string } | null> {
|
||||||
const response = await this.client!.models.generateContent({
|
const response = await this.client!.models.generateContent({
|
||||||
model,
|
model,
|
||||||
contents: prompt,
|
contents: prompt,
|
||||||
@@ -358,7 +370,7 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
|||||||
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
|
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
|
||||||
const finishReason = candidate?.finishReason || 'UNKNOWN';
|
const finishReason = candidate?.finishReason || 'UNKNOWN';
|
||||||
this.logger.warn(`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`);
|
this.logger.warn(`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`);
|
||||||
return null;
|
return { buffer: Buffer.from([]), mimeType: '', errorReason: finishReason };
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagePart = candidate.content.parts.find(
|
const imagePart = candidate.content.parts.find(
|
||||||
@@ -375,9 +387,10 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
|||||||
const textParts = candidate.content.parts.filter((p: any) => p.text);
|
const textParts = candidate.content.parts.filter((p: any) => p.text);
|
||||||
if (textParts.length > 0) {
|
if (textParts.length > 0) {
|
||||||
this.logger.warn(`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`);
|
this.logger.warn(`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`);
|
||||||
|
return { buffer: Buffer.from([]), mimeType: '', errorReason: 'TEXT_ONLY' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return { buffer: Buffer.from([]), mimeType: '', errorReason: 'NO_IMAGE_DATA' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Basit uyku fonksiyonu — retry aralarında kullanılır */
|
/** Basit uyku fonksiyonu — retry aralarında kullanılır */
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export class CreateFromTweetDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Min(15)
|
@Min(15)
|
||||||
@Max(90)
|
@Max(180)
|
||||||
targetDuration?: number;
|
targetDuration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,11 +261,17 @@ export class CreateFromYoutubeDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(200)
|
||||||
|
cinematicReference?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Min(15)
|
@Min(15)
|
||||||
@Max(90)
|
@Max(180)
|
||||||
targetDuration?: number;
|
targetDuration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,10 +306,70 @@ export class CreateFromDocumentDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(200)
|
||||||
|
cinematicReference?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Min(15)
|
@Min(15)
|
||||||
@Max(90)
|
@Max(180)
|
||||||
|
targetDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateFromExtractedTextDto {
|
||||||
|
@ApiProperty({ description: 'Çıkarılan metin' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Seçilen video konusu' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
topic: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Orijinal dosya adı' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
originalFilename?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Video dili (ISO 639-1)', default: 'tr' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(5)
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'En-boy oranı (PORTRAIT_9_16, LANDSCAPE_16_9, SQUARE_1_1)',
|
||||||
|
default: 'PORTRAIT_9_16',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(20)
|
||||||
|
aspectRatio?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Video stili (CINEMATIC, DOCUMENTARY, vb.)',
|
||||||
|
default: 'CINEMATIC',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(50)
|
||||||
|
videoStyle?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(200)
|
||||||
|
cinematicReference?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
@Min(15)
|
||||||
|
@Max(180)
|
||||||
targetDuration?: number;
|
targetDuration?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto } from './dto/project.dto';
|
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto } from './dto/project.dto';
|
||||||
|
|
||||||
@ApiTags('projects')
|
@ApiTags('projects')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -191,6 +191,39 @@ export class ProjectsController {
|
|||||||
return this.projectsService.createFromDocument(userId, file, dto);
|
return this.projectsService.createFromDocument(userId, file, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doküman yüklenip metni çıkarılır ve video konu önerileri üretilir.
|
||||||
|
*/
|
||||||
|
@Post('extract-document-topics')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Dosyadan metin çıkar ve konu önerileri al' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Metin ve konular başarıyla çıkarıldı' })
|
||||||
|
async extractDocumentTopics(
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
this.logger.log(`Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`);
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Dosya yüklenmedi');
|
||||||
|
}
|
||||||
|
return this.projectsService.extractDocumentTopics(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracted text ve seçilen konu üzerinden doğrudan proje oluşturur.
|
||||||
|
*/
|
||||||
|
@Post('document-from-topic')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Seçilen konu ve metin ile proje oluştur' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Seçilen konu baz alınarak proje oluşturuldu' })
|
||||||
|
async createFromTopic(@Body() dto: CreateFromExtractedTextDto, @Req() req: any) {
|
||||||
|
const userId = req.user?.id || req.user?.sub;
|
||||||
|
this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor. Konu: ${dto.topic}`);
|
||||||
|
return this.projectsService.createFromExtractedText(userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tekil sahne güncelleme (narrasyon, görsel prompt, süre).
|
* Tekil sahne güncelleme (narrasyon, görsel prompt, süre).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TransitionType } from '@prisma/client';
|
import { TransitionType, AspectRatio } from '@prisma/client';
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
import { VideoAiService } from '../video-ai/video-ai.service';
|
import { VideoAiService } from '../video-ai/video-ai.service';
|
||||||
import { VideoQueueModule } from '../video-queue/video-queue.module';
|
import { VideoQueueModule } from '../video-queue/video-queue.module';
|
||||||
@@ -13,9 +13,11 @@ import { XTwitterService } from '../x-twitter/x-twitter.service';
|
|||||||
import { GeminiService } from '../gemini/gemini.service';
|
import { GeminiService } from '../gemini/gemini.service';
|
||||||
import { StorageService } from '../storage/storage.service';
|
import { StorageService } from '../storage/storage.service';
|
||||||
import { ExtractorService } from '../extractor/extractor.service';
|
import { ExtractorService } from '../extractor/extractor.service';
|
||||||
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto } from './dto/project.dto';
|
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto } from './dto/project.dto';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
interface FindAllOptions {
|
interface FindAllOptions {
|
||||||
page: number;
|
page: number;
|
||||||
@@ -360,15 +362,21 @@ export class ProjectsService {
|
|||||||
aspectRatio: project.aspectRatio,
|
aspectRatio: project.aspectRatio,
|
||||||
videoStyle: project.videoStyle,
|
videoStyle: project.videoStyle,
|
||||||
targetDuration: project.targetDuration,
|
targetDuration: project.targetDuration,
|
||||||
scenes: project.scenes.map((s) => ({
|
scenes: project.scenes.map((s) => {
|
||||||
id: s.id,
|
const thumbnail = s.mediaAssets?.find(m => m.type === 'THUMBNAIL');
|
||||||
order: s.order,
|
const imagePath = thumbnail && thumbnail.s3Key ? this.storageService.getAbsolutePath(thumbnail.s3Key) : undefined;
|
||||||
narrationText: s.narrationText,
|
return {
|
||||||
visualPrompt: s.visualPrompt,
|
id: s.id,
|
||||||
subtitleText: s.subtitleText || s.narrationText,
|
order: s.order,
|
||||||
duration: s.duration,
|
narrationText: s.narrationText,
|
||||||
transitionType: s.transitionType,
|
visualPrompt: s.visualPrompt,
|
||||||
})),
|
subtitleText: s.subtitleText || s.narrationText,
|
||||||
|
duration: s.duration,
|
||||||
|
transitionType: s.transitionType,
|
||||||
|
imagePath,
|
||||||
|
ttsProvider: 'openai', // TODO: Make configurable from frontend or project settings
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.db.renderJob.update({
|
await this.db.renderJob.update({
|
||||||
@@ -622,31 +630,87 @@ export class ProjectsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF, Word vb. dokümandan metin çıkarır ve konu önerileri üretir.
|
||||||
|
*/
|
||||||
|
async extractDocumentTopics(file: Express.Multer.File) {
|
||||||
|
this.logger.log(`Belgeden konu önerileri çıkarılıyor: ${file.originalname}`);
|
||||||
|
|
||||||
|
let tempFilePath: string | null = null;
|
||||||
|
let extractedText = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (file.path) {
|
||||||
|
tempFilePath = file.path;
|
||||||
|
} else if (file.buffer) {
|
||||||
|
tempFilePath = path.join(os.tmpdir(), `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
|
||||||
|
await fs.writeFile(tempFilePath, file.buffer);
|
||||||
|
} else {
|
||||||
|
throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok).");
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedText = await this.extractorService.extractFromFile(tempFilePath, file.originalname, file.mimetype);
|
||||||
|
} finally {
|
||||||
|
if (tempFilePath && !file.path) {
|
||||||
|
await fs.unlink(tempFilePath).catch(e => this.logger.warn(`Temp dosya silinemedi: ${e.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extractedText || extractedText.trim().length === 0) {
|
||||||
|
throw new BadRequestException("Belgeden okunabilir metin çıkarılamadı.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kısa metinse doğrudan 1 konu öner (kendi başlığı gibi), uzunsa çoklu konu
|
||||||
|
let topics: string[] = [];
|
||||||
|
if (extractedText.length < 5000) {
|
||||||
|
topics = [file.originalname.split('.')[0] || "Belge Özeti"];
|
||||||
|
} else {
|
||||||
|
topics = await this.videoAiService.suggestDocumentTopics(extractedText, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: extractedText,
|
||||||
|
topics,
|
||||||
|
originalFilename: file.originalname
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PDF, Word vb. dokümandan proje oluşturur.
|
* PDF, Word vb. dokümandan proje oluşturur.
|
||||||
*/
|
*/
|
||||||
async createFromDocument(userId: string, file: Express.Multer.File, dto: CreateFromDocumentDto) {
|
async createFromDocument(userId: string, file: Express.Multer.File, dto: CreateFromDocumentDto) {
|
||||||
this.logger.log(`Belgeden proje oluşturuluyor: ${file.originalname}`);
|
this.logger.log(`Belgeden proje oluşturuluyor: ${file.originalname}`);
|
||||||
|
|
||||||
// Gelen dosyanın geçici path'i
|
let tempFilePath: string | null = null;
|
||||||
// Not: multer ile yüklendiğinde `file.path` üzerinden geçici dosya adresini alabiliyoruz
|
let extractedText = '';
|
||||||
if (!file.path) {
|
|
||||||
// Eğer memoryStorage kullanılıyorsa temp dizine yazılarak paslanabilir,
|
|
||||||
// bu örnekte form-data ile Python extractor'a gönderileceği varsayılıyor
|
|
||||||
throw new Error("Multer destPath bulunamadı, diskStorage kullanılmalıdır.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const extractedText = await this.extractorService.extractFromFile(file.path, file.originalname, file.mimetype);
|
try {
|
||||||
|
if (file.path) {
|
||||||
|
tempFilePath = file.path;
|
||||||
|
} else if (file.buffer) {
|
||||||
|
tempFilePath = path.join(os.tmpdir(), `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
|
||||||
|
await fs.writeFile(tempFilePath, file.buffer);
|
||||||
|
} else {
|
||||||
|
throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok).");
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedText = await this.extractorService.extractFromFile(tempFilePath, file.originalname, file.mimetype);
|
||||||
|
} finally {
|
||||||
|
if (tempFilePath && !file.path) {
|
||||||
|
await fs.unlink(tempFilePath).catch(e => this.logger.warn(`Temp dosya silinemedi: ${e.message}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Başlık ve prompt belirlenmesi
|
// Başlık ve prompt belirlenmesi
|
||||||
const title = dto.title || `${file.originalname} Özeti`;
|
const title = dto.title || `${file.originalname} Özeti`;
|
||||||
const prompt = `Aşağıda içeriği verilen dökümandan çarpıcı bir video senaryosu üret:\n\n${extractedText.substring(0, 15000)}`;
|
const fullAiPrompt = `Aşağıda içeriği verilen dökümandan çarpıcı bir video senaryosu üret:\n\n${extractedText.substring(0, 15000)}`;
|
||||||
|
const shortDbPrompt = `Belge üzerinden oluşturuldu: ${file.originalname}`;
|
||||||
|
|
||||||
const project = await this.db.project.create({
|
const project = await this.db.project.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description: `Belge üzerinden üretildi: ${file.originalname}`,
|
description: `Belge üzerinden üretildi: ${file.originalname}`,
|
||||||
prompt,
|
prompt: shortDbPrompt,
|
||||||
language: dto.language || 'tr',
|
language: dto.language || 'tr',
|
||||||
aspectRatio: dto.aspectRatio || 'PORTRAIT_9_16',
|
aspectRatio: dto.aspectRatio || 'PORTRAIT_9_16',
|
||||||
videoStyle: dto.videoStyle || 'CINEMATIC',
|
videoStyle: dto.videoStyle || 'CINEMATIC',
|
||||||
@@ -659,7 +723,7 @@ export class ProjectsService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const scriptJson = await this.videoAiService.generateVideoScript({
|
const scriptJson = await this.videoAiService.generateVideoScript({
|
||||||
topic: prompt,
|
topic: fullAiPrompt,
|
||||||
targetDurationSeconds: project.targetDuration,
|
targetDurationSeconds: project.targetDuration,
|
||||||
language: project.language,
|
language: project.language,
|
||||||
videoStyle: project.videoStyle,
|
videoStyle: project.videoStyle,
|
||||||
@@ -704,6 +768,82 @@ export class ProjectsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Çıkarılmış metin ve kullanıcının seçtiği bir "topic" üzerinden proje oluşturur.
|
||||||
|
*/
|
||||||
|
async createFromExtractedText(userId: string, dto: CreateFromExtractedTextDto) {
|
||||||
|
this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`);
|
||||||
|
|
||||||
|
const title = dto.topic;
|
||||||
|
// Tam prompt metni (AI'a gönderilecek)
|
||||||
|
const fullAiPrompt = `Aşağıda içeriği verilen metinden, özellikle "${dto.topic}" konusuna odaklanan çarpıcı bir video senaryosu üret:\n\n${dto.text.substring(0, 15000)}`;
|
||||||
|
// Veritabanına kaydedilecek kısa prompt metni (VarChar 2000 limitine takılmaması için)
|
||||||
|
const shortDbPrompt = `Belge/Metin üzerinden "${dto.topic}" konusu hedeflenerek oluşturuldu.`;
|
||||||
|
|
||||||
|
const project = await this.db.project.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description: dto.originalFilename ? `Belgeden üretildi: ${dto.originalFilename} (Konu: ${dto.topic})` : `Metinden üretildi (Konu: ${dto.topic})`,
|
||||||
|
prompt: shortDbPrompt,
|
||||||
|
language: dto.language || 'tr',
|
||||||
|
aspectRatio: (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
|
||||||
|
videoStyle: dto.videoStyle || 'CINEMATIC',
|
||||||
|
cinematicReference: dto.cinematicReference,
|
||||||
|
targetDuration: dto.targetDuration || 60,
|
||||||
|
status: 'GENERATING_SCRIPT',
|
||||||
|
userId,
|
||||||
|
sourceType: 'DOCUMENT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scriptJson = await this.videoAiService.generateVideoScript({
|
||||||
|
topic: fullAiPrompt,
|
||||||
|
targetDurationSeconds: project.targetDuration,
|
||||||
|
language: project.language,
|
||||||
|
videoStyle: project.videoStyle,
|
||||||
|
cinematicReference: project.cinematicReference ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenesData = scriptJson.scenes.map((scene: any) => ({
|
||||||
|
projectId: project.id,
|
||||||
|
order: scene.order,
|
||||||
|
title: scene.title || `Sahne ${scene.order}`,
|
||||||
|
narrationText: scene.narrationText,
|
||||||
|
visualPrompt: scene.visualPrompt,
|
||||||
|
subtitleText: scene.subtitleText,
|
||||||
|
duration: scene.durationSeconds,
|
||||||
|
transitionType: this.mapTransitionType(scene.transitionType),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.db.scene.createMany({ data: scenesData });
|
||||||
|
|
||||||
|
const updatedProject = await this.db.project.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
data: {
|
||||||
|
scriptJson: scriptJson as object,
|
||||||
|
status: 'DRAFT',
|
||||||
|
errorMessage: null,
|
||||||
|
scriptVersion: 1,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
scenes: { orderBy: { order: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedProject;
|
||||||
|
} catch (error) {
|
||||||
|
await this.db.project.update({
|
||||||
|
where: { id: project.id },
|
||||||
|
data: {
|
||||||
|
status: 'DRAFT',
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Konu bazlı senaryo üretimi sırasında hata',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tekil sahne güncelleme — narrasyon, görsel prompt, altyazı veya süre.
|
* Tekil sahne güncelleme — narrasyon, görsel prompt, altyazı veya süre.
|
||||||
*/
|
*/
|
||||||
@@ -856,13 +996,19 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
const styleLabel = cinematicRef
|
const styleLabel = cinematicRef
|
||||||
? `Style: ${project.videoStyle}, Cinematic reference: ${cinematicRef}`
|
? `Style: ${project.videoStyle}, Cinematic reference: ${cinematicRef}`
|
||||||
: `Style: ${project.videoStyle}`;
|
: `Style: ${project.videoStyle}`;
|
||||||
const imageResult = await this.geminiService.generateImage(
|
let imageResult = await this.geminiService.generateImage(
|
||||||
`${scene.visualPrompt}. ${styleLabel}`,
|
`${scene.visualPrompt}. ${styleLabel}`,
|
||||||
mappedRatio,
|
mappedRatio,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!imageResult) {
|
if (!imageResult) {
|
||||||
throw new BadRequestException('Görsel üretilemedi, servis yanıt vermedi');
|
this.logger.warn(`⚠️ Orijinal prompt ile görsel üretilemedi. Güvenli fallback deneniyor...`);
|
||||||
|
const safePrompt = `A cinematic, highly detailed abstract visualization matching the mood of: ${project.videoStyle}. Ensure professional quality, 8k resolution. Do not include specific people, recognizable faces, or real-world public figures.`;
|
||||||
|
imageResult = await this.geminiService.generateImage(safePrompt, mappedRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageResult) {
|
||||||
|
throw new BadRequestException('Görsel üretilemedi, güvenlik filtreleri veya servis hatası nedeniyle işlem başarısız oldu.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage'a kaydet
|
// Storage'a kaydet
|
||||||
|
|||||||
@@ -298,11 +298,11 @@ This is CRITICAL. All scenes in one project must feel like they belong to the sa
|
|||||||
Match the "videoStyle" to its corresponding visual DNA. These are your default creative parameters per style:
|
Match the "videoStyle" to its corresponding visual DNA. These are your default creative parameters per style:
|
||||||
|
|
||||||
CINEMATIC:
|
CINEMATIC:
|
||||||
Reference: Denis Villeneuve, Roger Deakins cinematography, Christopher Nolan IMAX
|
Reference: High-end cinematic production with professional cinematography techniques
|
||||||
Lighting: Dramatic key-and-fill, single strong motivated source, deep shadows
|
Lighting: Dramatic key-and-fill, single strong motivated source, deep shadows
|
||||||
Lens: 35mm anamorphic or 65mm IMAX, shallow DOF
|
Lens: 35mm anamorphic or 65mm IMAX, shallow DOF
|
||||||
Color: Teal-orange grade, desaturated midtones, crushed blacks
|
Color: Professional cinematic color grading, balanced contrast, atmospheric depth
|
||||||
Texture: Film grain, anamorphic lens flare, subtle vignette
|
Texture: Subtle organic film grain, anamorphic lens flare, cinematic light bloom
|
||||||
|
|
||||||
DOCUMENTARY:
|
DOCUMENTARY:
|
||||||
Reference: National Geographic, Planet Earth II, David Attenborough
|
Reference: National Geographic, Planet Earth II, David Attenborough
|
||||||
@@ -616,6 +616,51 @@ export class VideoAiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uzun metinlerden (kitap, uzun makale vb.) potansiyel video konuları çıkarır.
|
||||||
|
* Gemini 1.5 Flash kullanarak 3-4 çarpıcı YouTube video başlığı önerir.
|
||||||
|
*/
|
||||||
|
async suggestDocumentTopics(text: string, count: number = 4): Promise<string[]> {
|
||||||
|
this.logger.log(`Dokümandan konu önerileri çıkarılıyor... (Metin uzunluğu: ${text.length})`);
|
||||||
|
|
||||||
|
const systemPrompt = `You are an elite YouTube producer and content strategist.
|
||||||
|
Your task is to analyze the provided book/document extract and suggest exactly ${count} highly engaging, distinct video topics or angles that could be made into successful YouTube Shorts or videos.
|
||||||
|
|
||||||
|
REQUIREMENTS:
|
||||||
|
- Return ONLY a JSON array of strings. No markdown, no explanations, no wrapping object.
|
||||||
|
- Example: ["The Hidden Psychology of Habits", "Why Discipline Beats Motivation", "The 5-Second Rule Explained"]
|
||||||
|
- Each topic should be punchy, curiosity-driven, and clearly related to the core themes of the text.
|
||||||
|
- Language: Turkish.`;
|
||||||
|
|
||||||
|
const userPrompt = `Extract ${count} engaging video topics from this text:\n\n${text.substring(0, 20000)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.genAI.models.generateContent({
|
||||||
|
model: this.modelName,
|
||||||
|
contents: userPrompt,
|
||||||
|
config: {
|
||||||
|
systemInstruction: systemPrompt,
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.9,
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawText = response.text ?? '[]';
|
||||||
|
const topics: string[] = JSON.parse(rawText);
|
||||||
|
|
||||||
|
this.logger.log(`✅ ${topics.length} adet konu önerisi çıkarıldı.`);
|
||||||
|
return topics;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Konu çıkarma hatası: ${error instanceof Error ? error.message : 'Bilinmeyen'}`,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`Video konuları çıkarılamadı: ${error instanceof Error ? error.message : 'API hatası'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildUserPrompt(input: ScriptGenerationInput): string {
|
private buildUserPrompt(input: ScriptGenerationInput): string {
|
||||||
const langMap: Record<string, string> = {
|
const langMap: Record<string, string> = {
|
||||||
tr: 'Turkish', en: 'English', es: 'Spanish', de: 'German',
|
tr: 'Turkish', en: 'English', es: 'Spanish', de: 'German',
|
||||||
@@ -640,6 +685,8 @@ export class VideoAiService {
|
|||||||
`- Make it viral-worthy, visually stunning, and intellectually captivating\n` +
|
`- Make it viral-worthy, visually stunning, and intellectually captivating\n` +
|
||||||
`- The first 2 seconds must hook the viewer immediately\n` +
|
`- The first 2 seconds must hook the viewer immediately\n` +
|
||||||
`- Write narration that sounds HUMAN — avoid AI writing patterns\n` +
|
`- Write narration that sounds HUMAN — avoid AI writing patterns\n` +
|
||||||
|
`- WHITE-LABELING (CRITICAL): NEVER mention the original source, creator, author, URL, channel name, or @username. Present all content as if YOU are the original creator.\n` +
|
||||||
|
`- DO NOT include logos, handles, or mentions of the original source in your visual prompts.\n` +
|
||||||
`- Include SEO-optimized metadata with keywords and schema markup\n` +
|
`- Include SEO-optimized metadata with keywords and schema markup\n` +
|
||||||
`- Generate social media captions for YouTube, TikTok, Instagram, Twitter\n`;
|
`- Generate social media captions for YouTube, TikTok, Instagram, Twitter\n`;
|
||||||
|
|
||||||
@@ -684,11 +731,13 @@ export class VideoAiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt += `\nIMPORTANT:\n`;
|
prompt += `\nIMPORTANT WHITE-LABELING RULES (CRITICAL):\n`;
|
||||||
prompt += `- Analyze WHY this tweet went viral and capture that energy\n`;
|
prompt += `- Analyze the core message of the tweet and capture its energy.\n`;
|
||||||
prompt += `- The narration should feel like a reaction/commentary on the tweet content\n`;
|
prompt += `- You are creating ORIGINAL content. Do NOT act like you are reacting to or commenting on someone else's post.\n`;
|
||||||
prompt += `- Mention the original tweet author @${tw.authorUsername} naturally in narration\n`;
|
prompt += `- ABSOLUTELY DO NOT mention the original author (@${tw.authorUsername}), their real name, or the fact that this is from a tweet/X.\n`;
|
||||||
prompt += `- Use both the tweet's images as reference AND generate new AI visuals\n`;
|
prompt += `- DO NOT include any logos, usernames, or references to the original source in your visual prompts (e.g. no "@${tw.authorUsername} logo").\n`;
|
||||||
|
prompt += `- Present the facts, stories, or insights as if YOU are the original expert creator.\n`;
|
||||||
|
prompt += `- Use the tweet's images as reference for the visuals, but describe them generally without mentioning any source brands or handles.\n`;
|
||||||
prompt += `═══════════════════════════════\n`;
|
prompt += `═══════════════════════════════\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface VideoGenerationJobPayload {
|
|||||||
duration: number;
|
duration: number;
|
||||||
transitionType: string;
|
transitionType: string;
|
||||||
ambientSoundPrompt?: string; // AudioGen: sahne bazlı ortam sesi
|
ambientSoundPrompt?: string; // AudioGen: sahne bazlı ortam sesi
|
||||||
|
imagePath?: string; // Gemini'den üretilen görselin yerel yolu
|
||||||
|
ttsProvider?: string; // openai veya elevenlabs
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma", "media-worker"]
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -20,6 +20,12 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false,
|
||||||
}
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"media-worker"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user