main
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-04-25 14:37:46 +02:00
parent ad5a97a4fd
commit 9d8c34b39d
34 changed files with 5853 additions and 164 deletions
-6
View File
@@ -47,16 +47,10 @@ public class DatabaseService
var sql = @"
UPDATE ""RenderJob""
SET ""status"" = @status::""RenderJobStatus"",
""progress"" = @progress,
""currentStage"" = CASE WHEN @stage IS NOT NULL THEN @stage::""RenderStage"" ELSE ""currentStage"" END,
""errorMessage"" = COALESCE(@errorMessage, ""errorMessage""),
""errorStack"" = COALESCE(@errorStack, ""errorStack""),
""processingTimeMs"" = COALESCE(@processingTimeMs, ""processingTimeMs""),
""workerVersion"" = COALESCE(@workerVersion, ""workerVersion""),
""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()
WHERE ""id"" = @id";
+91
View File
@@ -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"
};
}
}
+89
View File
@@ -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;
}
}
+33 -94
View File
@@ -23,8 +23,10 @@ public class VideoRenderPipeline
private readonly ILogger<VideoRenderPipeline> _logger;
private readonly HiggsFieldService _higgsField;
private readonly TtsService _tts;
private readonly OpenAiTtsService _openAiTts;
private readonly SunoMusicService _sunoMusic;
private readonly AudioCraftService _audioCraft;
private readonly RemotionService _remotion;
private readonly FFmpegService _ffmpeg;
private readonly S3StorageService _s3;
private readonly FFmpegSettings _ffmpegSettings;
@@ -33,8 +35,10 @@ public class VideoRenderPipeline
ILogger<VideoRenderPipeline> logger,
HiggsFieldService higgsField,
TtsService tts,
OpenAiTtsService openAiTts,
SunoMusicService sunoMusic,
AudioCraftService audioCraft,
RemotionService remotion,
FFmpegService ffmpeg,
S3StorageService s3,
IOptions<FFmpegSettings> ffmpegSettings)
@@ -42,8 +46,10 @@ public class VideoRenderPipeline
_logger = logger;
_higgsField = higgsField;
_tts = tts;
_openAiTts = openAiTts;
_sunoMusic = sunoMusic;
_audioCraft = audioCraft;
_remotion = remotion;
_ffmpeg = ffmpeg;
_s3 = s3;
_ffmpegSettings = ffmpegSettings.Value;
@@ -67,30 +73,13 @@ public class VideoRenderPipeline
try
{
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;
// ═══════════════════════════════════════
// ADIM 1: Her sahne için video klip üret
// ADIM 1: Video Klip Üretimi (ATLANDI - REMOTION YAPACAK)
// ═══════════════════════════════════════
_logger.LogInformation(
"📹 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);
_logger.LogInformation("📹 Adım 1: Video üretimi atlandı, görseller doğrudan Remotion'da Ken Burns ile işlenecek.");
// ═══════════════════════════════════════
// ADIM 2: Her sahne için TTS narration üret
@@ -107,8 +96,8 @@ public class VideoRenderPipeline
() =>
{
completedSteps++;
var progress = 60 + (int)(completedSteps / (double)totalSteps * 10); // TTS %60-70
return progressCallback(Math.Min(progress, 70), "TTS_GENERATION");
var progress = (int)(completedSteps / (double)totalSteps * 40); // TTS %0-40
return progressCallback(progress, "TTS_GENERATION");
}, ct));
}
@@ -119,7 +108,7 @@ public class VideoRenderPipeline
// ADIM 3: Background müzik üret
// ═══════════════════════════════════════
_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
?? "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
// ═══════════════════════════════════════
_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>();
try
@@ -184,67 +173,16 @@ public class VideoRenderPipeline
_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");
await progressCallback(83, "MEDIA_MERGE");
_logger.LogInformation("🎬 Adım 5/6: Remotion render — Ken Burns + audio merge + subtitle");
await progressCallback(75, "MEDIA_MERGE");
var mergedScenePaths = new List<string>();
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);
var finalLocalPath = await _remotion.RenderVideoAsync(
job.ProjectId, projectDir, scenes, allMediaFiles, musicFile?.LocalPath, job.TargetDuration, ct);
allMediaFiles.Add(new GeneratedMediaFile
{
@@ -255,7 +193,7 @@ public class VideoRenderPipeline
MimeType = "video/mp4"
});
await progressCallback(92, "MEDIA_MERGE");
await progressCallback(90, "MEDIA_MERGE");
// ═══════════════════════════════════════
// 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(
ScenePayload scene, string outputDir, string voiceStyle,
Func<Task> onComplete, CancellationToken ct)
{
var result = await _tts.GenerateNarrationAsync(
scene, outputDir, voiceStyle, ct);
GeneratedMediaFile result;
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();
return result;
}