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
@@ -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
View File
@@ -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>
+7
View File
@@ -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>();
-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;
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
.env
# Ignore the output video from Git but not videos you import into src/.
out
+5
View File
@@ -0,0 +1,5 @@
{
"useTabs": false,
"bracketSpacing": true,
"tabWidth": 2
}
+54
View File
@@ -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).
+3
View File
@@ -0,0 +1,3 @@
import { config } from "@remotion/eslint-config-flat";
export default config;
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
]
}
+13
View File
@@ -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;
};
+135
View File
@@ -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>
);
};
+21
View File
@@ -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: [],
}}
/>
</>
);
};
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
+15
View File
@@ -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
View File
@@ -3,8 +3,11 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["i18n/**/*"],
"deleteOutDir": false,
"assets": [
"**/*.json",
"i18n/**/*"
],
"watchAssets": true
}
}
+1 -1
View File
@@ -38,7 +38,6 @@
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.17",
"@prisma/client": "^5.22.0",
"@types/sharp": "^0.32.0",
"axios": "^1.15.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.66.4",
@@ -79,6 +78,7 @@
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.4",
"@types/passport-jwt": "^4.0.1",
"@types/sharp": "^0.32.0",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
+19
View File
@@ -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.
+3 -3
View File
@@ -56,9 +56,6 @@ importers:
'@prisma/client':
specifier: ^5.22.0
version: 5.22.0(prisma@5.22.0)
'@types/sharp':
specifier: ^0.32.0
version: 0.32.0
axios:
specifier: ^1.15.0
version: 1.15.0
@@ -174,6 +171,9 @@ importers:
'@types/passport-jwt':
specifier: ^4.0.1
version: 4.0.1
'@types/sharp':
specifier: ^0.32.0
version: 0.32.0
'@types/supertest':
specifier: ^6.0.2
version: 6.0.3
+7 -5
View File
@@ -253,9 +253,14 @@ export class AuthService {
tenantId: user.tenantId || undefined,
};
const isAdmin = roles.includes('admin');
const accessExpiration = isAdmin
? '7d'
: this.configService.get('JWT_ACCESS_EXPIRATION', '15m');
// Generate access token
const accessToken = this.jwtService.sign(payload, {
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
expiresIn: accessExpiration as any,
});
// Generate refresh token
@@ -276,10 +281,7 @@ export class AuthService {
return {
accessToken,
refreshToken: refreshTokenValue,
expiresIn:
this.parseExpiration(
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
) / 1000, // Convert to seconds
expiresIn: this.parseExpiration(accessExpiration) / 1000, // Convert to seconds
user: {
id: user.id,
email: user.email,
+23 -10
View File
@@ -279,11 +279,19 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
try {
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
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`);
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);
} catch (err1: any) {
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 {
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
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`);
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) {
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`);
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) {
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(
model: string,
prompt: string,
): Promise<{ buffer: Buffer; mimeType: string } | null> {
): Promise<{ buffer: Buffer; mimeType: string; errorReason?: string } | null> {
const response = await this.client!.models.generateContent({
model,
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) {
const finishReason = candidate?.finishReason || 'UNKNOWN';
this.logger.warn(`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`);
return null;
return { buffer: Buffer.from([]), mimeType: '', errorReason: finishReason };
}
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);
if (textParts.length > 0) {
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 */
+69 -3
View File
@@ -214,7 +214,7 @@ export class CreateFromTweetDto {
@IsInt()
@IsOptional()
@Min(15)
@Max(90)
@Max(180)
targetDuration?: number;
}
@@ -261,11 +261,17 @@ export class CreateFromYoutubeDto {
@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(90)
@Max(180)
targetDuration?: number;
}
@@ -300,10 +306,70 @@ export class CreateFromDocumentDto {
@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(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;
}
+34 -1
View File
@@ -26,7 +26,7 @@ import {
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
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')
@ApiBearerAuth()
@@ -191,6 +191,39 @@ export class ProjectsController {
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).
*/
+170 -24
View File
@@ -4,7 +4,7 @@ import {
BadRequestException,
Logger,
} from '@nestjs/common';
import { TransitionType } from '@prisma/client';
import { TransitionType, AspectRatio } from '@prisma/client';
import { PrismaService } from '../../database/prisma.service';
import { VideoAiService } from '../video-ai/video-ai.service';
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 { StorageService } from '../storage/storage.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 * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
interface FindAllOptions {
page: number;
@@ -360,15 +362,21 @@ export class ProjectsService {
aspectRatio: project.aspectRatio,
videoStyle: project.videoStyle,
targetDuration: project.targetDuration,
scenes: project.scenes.map((s) => ({
id: s.id,
order: s.order,
narrationText: s.narrationText,
visualPrompt: s.visualPrompt,
subtitleText: s.subtitleText || s.narrationText,
duration: s.duration,
transitionType: s.transitionType,
})),
scenes: project.scenes.map((s) => {
const thumbnail = s.mediaAssets?.find(m => m.type === 'THUMBNAIL');
const imagePath = thumbnail && thumbnail.s3Key ? this.storageService.getAbsolutePath(thumbnail.s3Key) : undefined;
return {
id: s.id,
order: s.order,
narrationText: s.narrationText,
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({
@@ -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.
*/
async createFromDocument(userId: string, file: Express.Multer.File, dto: CreateFromDocumentDto) {
this.logger.log(`Belgeden proje oluşturuluyor: ${file.originalname}`);
// Gelen dosyanın geçici path'i
// Not: multer ile yüklendiğinde `file.path` üzerinden geçici dosya adresini alabiliyoruz
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.");
}
let tempFilePath: string | null = null;
let extractedText = '';
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
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({
data: {
title,
description: `Belge üzerinden üretildi: ${file.originalname}`,
prompt,
prompt: shortDbPrompt,
language: dto.language || 'tr',
aspectRatio: dto.aspectRatio || 'PORTRAIT_9_16',
videoStyle: dto.videoStyle || 'CINEMATIC',
@@ -659,7 +723,7 @@ export class ProjectsService {
try {
const scriptJson = await this.videoAiService.generateVideoScript({
topic: prompt,
topic: fullAiPrompt,
targetDurationSeconds: project.targetDuration,
language: project.language,
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.
*/
@@ -856,13 +996,19 @@ Sadece bu tek sahneyi üret. JSON formatında:
const styleLabel = cinematicRef
? `Style: ${project.videoStyle}, Cinematic reference: ${cinematicRef}`
: `Style: ${project.videoStyle}`;
const imageResult = await this.geminiService.generateImage(
let imageResult = await this.geminiService.generateImage(
`${scene.visualPrompt}. ${styleLabel}`,
mappedRatio,
);
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
+57 -8
View File
@@ -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:
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
Lens: 35mm anamorphic or 65mm IMAX, shallow DOF
Color: Teal-orange grade, desaturated midtones, crushed blacks
Texture: Film grain, anamorphic lens flare, subtle vignette
Color: Professional cinematic color grading, balanced contrast, atmospheric depth
Texture: Subtle organic film grain, anamorphic lens flare, cinematic light bloom
DOCUMENTARY:
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 {
const langMap: Record<string, string> = {
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` +
`- The first 2 seconds must hook the viewer immediately\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` +
`- Generate social media captions for YouTube, TikTok, Instagram, Twitter\n`;
@@ -684,11 +731,13 @@ export class VideoAiService {
}
}
prompt += `\nIMPORTANT:\n`;
prompt += `- Analyze WHY this tweet went viral and capture that energy\n`;
prompt += `- The narration should feel like a reaction/commentary on the tweet content\n`;
prompt += `- Mention the original tweet author @${tw.authorUsername} naturally in narration\n`;
prompt += `- Use both the tweet's images as reference AND generate new AI visuals\n`;
prompt += `\nIMPORTANT WHITE-LABELING RULES (CRITICAL):\n`;
prompt += `- Analyze the core message of the tweet and capture its energy.\n`;
prompt += `- You are creating ORIGINAL content. Do NOT act like you are reacting to or commenting on someone else's post.\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 += `- 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`;
}
@@ -21,6 +21,8 @@ export interface VideoGenerationJobPayload {
duration: number;
transitionType: string;
ambientSoundPrompt?: string; // AudioGen: sahne bazlı ortam sesi
imagePath?: string; // Gemini'den üretilen görselin yerel yolu
ttsProvider?: string; // openai veya elevenlabs
}>;
}
+1 -1
View File
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma"]
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma", "media-worker"]
}
+8 -2
View File
@@ -20,6 +20,12 @@
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
"noFallthroughCasesInSwitch": false,
"jsx": "react-jsx"
},
"exclude": [
"node_modules",
"dist",
"media-worker"
]
}