generated from fahricansecer/boilerplate-be
@@ -35,6 +35,8 @@ public class ApiSettings
|
||||
public string TtsVoiceId { get; set; } = "pNInz6obpgDQGcFmaJgB";
|
||||
public string OpenAiApiKey { get; set; } = string.Empty;
|
||||
public string OpenAiTtsVoiceId { get; set; } = "alloy";
|
||||
public string MinimaxApiKey { get; set; } = string.Empty;
|
||||
public string MinimaxTtsVoiceId { get; set; } = "male-qn-qingse";
|
||||
public string SunoBaseUrl { get; set; } = string.Empty;
|
||||
public string SunoApiKey { get; set; } = string.Empty;
|
||||
public string CoreApiBaseUrl { get; set; } = "http://localhost:3000/api";
|
||||
|
||||
@@ -29,6 +29,9 @@ public class VideoGenerationJob
|
||||
[JsonPropertyName("videoStyle")]
|
||||
public string VideoStyle { get; set; } = "CINEMATIC";
|
||||
|
||||
[JsonPropertyName("visualEffect")]
|
||||
public string VisualEffect { get; set; } = "kenburns";
|
||||
|
||||
[JsonPropertyName("targetDuration")]
|
||||
public int TargetDuration { get; set; } = 60;
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ try
|
||||
builder.Services.AddHttpClient<OpenAiTtsService>("OpenAITTS")
|
||||
.AddPolicyHandler(combinedPolicy);
|
||||
|
||||
builder.Services.AddHttpClient<MinimaxTtsService>("MinimaxTTS")
|
||||
.AddPolicyHandler(combinedPolicy);
|
||||
|
||||
builder.Services.AddHttpClient<SunoMusicService>("Suno")
|
||||
.AddPolicyHandler(combinedPolicy);
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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>
|
||||
/// Minimax TTS API Client — Metin → Ses dönüşümü.
|
||||
/// </summary>
|
||||
public class MinimaxTtsService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<MinimaxTtsService> _logger;
|
||||
private readonly ApiSettings _settings;
|
||||
|
||||
public MinimaxTtsService(
|
||||
HttpClient httpClient,
|
||||
ILogger<MinimaxTtsService> logger,
|
||||
IOptions<ApiSettings> settings)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
|
||||
_httpClient.BaseAddress = new Uri("https://api.minimax.chat/v1/");
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.MinimaxApiKey);
|
||||
_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(
|
||||
"🎙️ Minimax 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.MinimaxTtsVoiceId : voiceStyle;
|
||||
|
||||
var requestBody = new
|
||||
{
|
||||
model = "speech-01-turbo",
|
||||
text = scene.NarrationText,
|
||||
voice_setting = new
|
||||
{
|
||||
voice_id = voiceId,
|
||||
speed = 1.0,
|
||||
vol = 1.0,
|
||||
pitch = 0
|
||||
},
|
||||
audio_setting = new
|
||||
{
|
||||
sample_rate = 32000,
|
||||
bitrate = 128000,
|
||||
format = "mp3",
|
||||
channel = 1
|
||||
}
|
||||
};
|
||||
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(requestBody),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await _httpClient.PostAsync("t2a_v2", content, ct);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// Minimax T2A V2 returns JSON with data.audio containing hex string
|
||||
var responseString = await response.Content.ReadAsStringAsync(ct);
|
||||
var jsonResponse = JsonSerializer.Deserialize<JsonElement>(responseString);
|
||||
|
||||
if (jsonResponse.TryGetProperty("data", out var dataElement) && dataElement.TryGetProperty("audio", out var audioHex))
|
||||
{
|
||||
var hexString = audioHex.GetString() ?? "";
|
||||
byte[] audioBytes = ConvertHexStringToByteArray(hexString);
|
||||
|
||||
var outputPath = Path.Combine(outputDirectory, $"scene_{scene.Order:D2}_narration.mp3");
|
||||
await File.WriteAllBytesAsync(outputPath, audioBytes, ct);
|
||||
|
||||
var fileInfo = new FileInfo(outputPath);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Minimax 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 = "minimax"
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception("Minimax API response invalid: " + responseString);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ConvertHexStringToByteArray(string hexString)
|
||||
{
|
||||
if (hexString.Length % 2 != 0)
|
||||
{
|
||||
throw new ArgumentException("Hex string must have an even length.");
|
||||
}
|
||||
|
||||
byte[] data = new byte[hexString.Length / 2];
|
||||
for (int index = 0; index < data.Length; index++)
|
||||
{
|
||||
string byteValue = hexString.Substring(index * 2, 2);
|
||||
data[index] = byte.Parse(byteValue, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public class RemotionService
|
||||
List<GeneratedMediaFile> generatedMedia,
|
||||
string? musicPath,
|
||||
int targetDurationSeconds,
|
||||
string visualEffect,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("🎬 Remotion render başlatılıyor — Project: {Id}", projectId);
|
||||
@@ -32,6 +33,7 @@ public class RemotionService
|
||||
var props = new
|
||||
{
|
||||
musicPath = musicPath,
|
||||
visualEffect = visualEffect,
|
||||
scenes = scenes.Select(s => new
|
||||
{
|
||||
imagePath = s.ImagePath,
|
||||
|
||||
@@ -24,6 +24,7 @@ public class VideoRenderPipeline
|
||||
private readonly HiggsFieldService _higgsField;
|
||||
private readonly TtsService _tts;
|
||||
private readonly OpenAiTtsService _openAiTts;
|
||||
private readonly MinimaxTtsService _minimaxTts;
|
||||
private readonly SunoMusicService _sunoMusic;
|
||||
private readonly AudioCraftService _audioCraft;
|
||||
private readonly RemotionService _remotion;
|
||||
@@ -36,6 +37,7 @@ public class VideoRenderPipeline
|
||||
HiggsFieldService higgsField,
|
||||
TtsService tts,
|
||||
OpenAiTtsService openAiTts,
|
||||
MinimaxTtsService minimaxTts,
|
||||
SunoMusicService sunoMusic,
|
||||
AudioCraftService audioCraft,
|
||||
RemotionService remotion,
|
||||
@@ -47,6 +49,7 @@ public class VideoRenderPipeline
|
||||
_higgsField = higgsField;
|
||||
_tts = tts;
|
||||
_openAiTts = openAiTts;
|
||||
_minimaxTts = minimaxTts;
|
||||
_sunoMusic = sunoMusic;
|
||||
_audioCraft = audioCraft;
|
||||
_remotion = remotion;
|
||||
@@ -176,13 +179,48 @@ public class VideoRenderPipeline
|
||||
await progressCallback(70, "AMBIENT_GENERATION");
|
||||
|
||||
// ═══════════════════════════════════════
|
||||
// ADIM 5: REMOTION — Video render (Ken Burns + Audio Merge + Subtitles)
|
||||
// ADIM 5: REMOTION — Segmented Video render + FFmpeg Merge
|
||||
// ═══════════════════════════════════════
|
||||
_logger.LogInformation("🎬 Adım 5/6: Remotion render — Ken Burns + audio merge + subtitle");
|
||||
await progressCallback(75, "MEDIA_MERGE");
|
||||
_logger.LogInformation("🎬 Adım 5/6: Remotion Segmented Render — Ken Burns + audio merge + subtitle");
|
||||
await progressCallback(70, "MEDIA_MERGE");
|
||||
|
||||
var finalLocalPath = await _remotion.RenderVideoAsync(
|
||||
job.ProjectId, projectDir, scenes, allMediaFiles, musicFile?.LocalPath, job.TargetDuration, ct);
|
||||
int chunkSize = 20; // 20 scenes per chunk to prevent OOM
|
||||
var chunkPaths = new List<string>();
|
||||
var chunkIndex = 0;
|
||||
|
||||
for (int i = 0; i < scenes.Count; i += chunkSize)
|
||||
{
|
||||
var chunkScenes = scenes.Skip(i).Take(chunkSize).ToList();
|
||||
chunkIndex++;
|
||||
|
||||
_logger.LogInformation("Render Chunk {ChunkIndex} (Scenes {Start} to {End})",
|
||||
chunkIndex, i + 1, i + chunkScenes.Count);
|
||||
|
||||
// Pass null for musicPath so Remotion doesn't add music to each chunk
|
||||
var chunkPath = await _remotion.RenderVideoAsync(
|
||||
$"{job.ProjectId}_chunk_{chunkIndex}",
|
||||
projectDir,
|
||||
chunkScenes,
|
||||
allMediaFiles,
|
||||
null, // No music per chunk
|
||||
0,
|
||||
job.VisualEffect,
|
||||
ct);
|
||||
|
||||
chunkPaths.Add(chunkPath);
|
||||
|
||||
var progress = 70 + (int)(20.0 * (i + chunkScenes.Count) / scenes.Count);
|
||||
await progressCallback(progress, "MEDIA_MERGE");
|
||||
}
|
||||
|
||||
_logger.LogInformation("🎬 Chunklar birleştiriliyor ve müzik ekleniyor (FFmpeg)");
|
||||
var finalLocalPath = await _ffmpeg.ConcatenateAndFinalize(
|
||||
chunkPaths,
|
||||
musicFile?.LocalPath,
|
||||
projectDir,
|
||||
job.ProjectId,
|
||||
job.TargetDuration,
|
||||
ct);
|
||||
|
||||
allMediaFiles.Add(new GeneratedMediaFile
|
||||
{
|
||||
@@ -234,6 +272,10 @@ public class VideoRenderPipeline
|
||||
{
|
||||
result = await _openAiTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(scene.TtsProvider) && scene.TtsProvider.Equals("minimax", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result = await _minimaxTts.GenerateNarrationAsync(scene, outputDir, voiceStyle, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default: ElevenLabs
|
||||
|
||||
@@ -20,9 +20,10 @@ export type SceneProp = {
|
||||
export type MainVideoProps = {
|
||||
scenes: SceneProp[];
|
||||
musicPath?: string;
|
||||
visualEffect?: string;
|
||||
};
|
||||
|
||||
export const MainVideo: React.FC<MainVideoProps> = ({ scenes, musicPath }) => {
|
||||
export const MainVideo: React.FC<MainVideoProps> = ({ scenes, musicPath, visualEffect }) => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Helper: map local absolute path to standard file:// URL so Remotion Chromium can load it
|
||||
@@ -50,7 +51,7 @@ export const MainVideo: React.FC<MainVideoProps> = ({ scenes, musicPath }) => {
|
||||
from={startFrame}
|
||||
durationInFrames={scene.durationInFrames}
|
||||
>
|
||||
<SceneRenderer scene={scene} index={index} />
|
||||
<SceneRenderer scene={scene} index={index} visualEffect={visualEffect} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
@@ -58,18 +59,40 @@ export const MainVideo: React.FC<MainVideoProps> = ({ scenes, musicPath }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SceneRenderer: React.FC<{ scene: SceneProp; index: number }> = ({ scene, index }) => {
|
||||
const SceneRenderer: React.FC<{ scene: SceneProp; index: number; visualEffect?: string }> = ({ scene, index, visualEffect }) => {
|
||||
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" }
|
||||
);
|
||||
// Base scale for kenburns and general movement
|
||||
let scale = 1;
|
||||
let filter = "";
|
||||
let mixBlendMode: any = "normal";
|
||||
|
||||
// If no effect or kenburns, apply the default Ken Burns
|
||||
if (!visualEffect || visualEffect === "kenburns" || visualEffect === "filmgrain" || visualEffect === "lightleaks" || visualEffect === "sparkles") {
|
||||
scale = interpolate(
|
||||
frame,
|
||||
[0, scene.durationInFrames],
|
||||
index % 2 === 0 ? [1, 1.1] : [1.1, 1],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
}
|
||||
|
||||
if (visualEffect === "glitch") {
|
||||
// Quick jitter
|
||||
const jitter = Math.sin(frame * 0.5) * 5;
|
||||
const jitterScale = index % 2 === 0 ? 1.05 : 1.0;
|
||||
scale = jitterScale + Math.abs(jitter) * 0.01;
|
||||
if (frame % 15 < 3) {
|
||||
filter = `hue-rotate(${jitter * 10}deg) contrast(150%)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Common effect overlays
|
||||
const noiseOpacity = interpolate(frame % 10, [0, 5, 10], [0.1, 0.15, 0.1]);
|
||||
const lightLeakOpacity = interpolate(Math.sin(frame * 0.05), [-1, 1], [0.1, 0.4]);
|
||||
|
||||
const makeFileUrl = (path?: string) => {
|
||||
if (!path) return undefined;
|
||||
@@ -84,6 +107,8 @@ const SceneRenderer: React.FC<{ scene: SceneProp; index: number }> = ({ scene, i
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
filter: filter ? filter : undefined,
|
||||
mixBlendMode: mixBlendMode
|
||||
}}
|
||||
>
|
||||
{scene.imagePath ? (
|
||||
@@ -98,6 +123,48 @@ const SceneRenderer: React.FC<{ scene: SceneProp; index: number }> = ({ scene, i
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{visualEffect === "filmgrain" && (
|
||||
<AbsoluteFill style={{
|
||||
opacity: noiseOpacity,
|
||||
backgroundColor: "white",
|
||||
mixBlendMode: "overlay",
|
||||
filter: "url(#noiseFilter)"
|
||||
}}>
|
||||
<svg>
|
||||
<filter id="noiseFilter">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="3" stitchTiles="stitch"/>
|
||||
</filter>
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
)}
|
||||
|
||||
{visualEffect === "lightleaks" && (
|
||||
<AbsoluteFill style={{
|
||||
background: "linear-gradient(45deg, rgba(255,100,0,0) 0%, rgba(255,50,0,0.5) 50%, rgba(255,0,0,0) 100%)",
|
||||
opacity: lightLeakOpacity,
|
||||
mixBlendMode: "screen",
|
||||
transform: `scale(1.5) rotate(${frame * 0.5}deg)`
|
||||
}} />
|
||||
)}
|
||||
|
||||
{visualEffect === "sparkles" && (
|
||||
<AbsoluteFill style={{
|
||||
background: "radial-gradient(circle at center, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0) 10%)",
|
||||
backgroundSize: "50px 50px",
|
||||
backgroundPosition: `${frame}px ${frame * 2}px`,
|
||||
opacity: interpolate(Math.sin(frame * 0.1), [-1, 1], [0.3, 0.8]),
|
||||
mixBlendMode: "screen"
|
||||
}} />
|
||||
)}
|
||||
|
||||
{visualEffect === "glitch" && frame % 20 < 4 && (
|
||||
<AbsoluteFill style={{
|
||||
backgroundColor: "rgba(255, 0, 0, 0.2)",
|
||||
mixBlendMode: "color-burn",
|
||||
transform: `translateX(${Math.random() * 20 - 10}px)`
|
||||
}} />
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
|
||||
{/* Audio Tracks */}
|
||||
|
||||
Reference in New Issue
Block a user