generated from fahricansecer/boilerplate-be
@@ -33,6 +33,8 @@ public class ApiSettings
|
||||
public string TtsBaseUrl { get; set; } = string.Empty;
|
||||
public string TtsApiKey { get; set; } = string.Empty;
|
||||
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 SunoApiKey { get; set; } = string.Empty;
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
# FFmpeg ve Globalization kurulumu (ARM64 native Alpine paketi)
|
||||
RUN apk add --no-cache ffmpeg font-dejavu icu-libs
|
||||
# FFmpeg, Node.js (Remotion için) ve Chromium (Puppeteer/Remotion için) kurulumu
|
||||
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
|
||||
|
||||
@@ -23,8 +24,15 @@ RUN mkdir -p /tmp/contgen-render
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
ENTRYPOINT ["dotnet", "SaasMediaWorker.dll"]
|
||||
|
||||
@@ -145,6 +145,12 @@ public class ScenePayload
|
||||
|
||||
[JsonPropertyName("ambientSoundPrompt")]
|
||||
public string? AmbientSoundPrompt { get; set; }
|
||||
|
||||
[JsonPropertyName("imagePath")]
|
||||
public string? ImagePath { get; set; }
|
||||
|
||||
[JsonPropertyName("ttsProvider")]
|
||||
public string? TtsProvider { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -67,15 +67,22 @@ try
|
||||
builder.Services.AddHttpClient<TtsService>("TTS")
|
||||
.AddPolicyHandler(combinedPolicy);
|
||||
|
||||
builder.Services.AddHttpClient<OpenAiTtsService>("OpenAITTS")
|
||||
.AddPolicyHandler(combinedPolicy);
|
||||
|
||||
builder.Services.AddHttpClient<SunoMusicService>("Suno")
|
||||
.AddPolicyHandler(combinedPolicy);
|
||||
|
||||
builder.Services.AddHttpClient<AudioCraftService>("AudioCraft")
|
||||
.AddPolicyHandler(combinedPolicy);
|
||||
|
||||
builder.Services.AddHttpClient<ApiNotificationService>("CoreAPI")
|
||||
.AddPolicyHandler(retryPolicy);
|
||||
|
||||
// Service registrations
|
||||
builder.Services.AddSingleton<S3StorageService>();
|
||||
builder.Services.AddSingleton<FFmpegService>();
|
||||
builder.Services.AddSingleton<RemotionService>();
|
||||
builder.Services.AddSingleton<VideoRenderPipeline>();
|
||||
builder.Services.AddSingleton<DatabaseService>();
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user