Stylique commited on
Commit
8431e01
·
verified ·
1 Parent(s): 27f2a49

Upload 10 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM huggingface/transformers-pytorch-gpu:latest
2
+
3
+ # Install Node.js 20
4
+ RUN apt-get update && apt-get install -y ca-certificates curl gnupg && \
5
+ mkdir -p /etc/apt/keyrings && \
6
+ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
7
+ echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \
8
+ apt-get update && \
9
+ apt-get install -y nodejs
10
+
11
+ # Install Remotion dependencies + GPU-accelerated FFmpeg
12
+ RUN apt-get update && \
13
+ apt-get install -y \
14
+ ffmpeg \
15
+ chromium \
16
+ chromium-sandbox \
17
+ libnss3 \
18
+ libnspr4 \
19
+ libatk1.0-0 \
20
+ libatk-bridge2.0-0 \
21
+ libcups2 \
22
+ libdrm2 \
23
+ libdbus-1-3 \
24
+ libxkbcommon0 \
25
+ libxcomposite1 \
26
+ libxdamage1 \
27
+ libxfixes3 \
28
+ libxrandr2 \
29
+ libgbm1 \
30
+ libasound2 \
31
+ libpango-1.0-0 \
32
+ libcairo2 \
33
+ && apt-get clean \
34
+ && rm -rf /var/lib/apt/lists/*
35
+
36
+ # Set Chrome path for Remotion
37
+ ENV CHROME_BIN=/usr/bin/chromium
38
+ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
39
+ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
40
+
41
+ WORKDIR /app
42
+
43
+ # Copy package files
44
+ COPY package*.json ./
45
+
46
+ # Install dependencies
47
+ RUN npm install
48
+
49
+ # Copy application files
50
+ COPY . .
51
+
52
+ # Expose port
53
+ EXPOSE 7860
54
+
55
+ # Start server
56
+ CMD ["npm", "start"]
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "facelessflow-renderer",
3
+ "version": "1.0.0",
4
+ "description": "Video rendering service for FacelessFlowAI using Remotion",
5
+ "main": "server.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node server.js",
9
+ "dev": "node --watch server.js"
10
+ },
11
+ "dependencies": {
12
+ "@remotion/bundler": "^4.0.0",
13
+ "@remotion/media-utils": "^4.0.406",
14
+ "@remotion/renderer": "^4.0.0",
15
+ "@supabase/supabase-js": "^2.90.1",
16
+ "cors": "^2.8.5",
17
+ "express": "^4.18.2",
18
+ "remotion": "^4.0.0"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ }
23
+ }
remotion/AudioWave.tsx ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useAudioData, visualizeAudio } from '@remotion/media-utils';
3
+ import { useCurrentFrame, useVideoConfig } from 'remotion';
4
+
5
+ type Props = {
6
+ audioUrl: string;
7
+ style?: string; // Kept for prop compatibility
8
+ position: 'bottom' | 'center' | 'top' | 'mid-bottom';
9
+ color: string;
10
+ };
11
+
12
+ export const AudioWave: React.FC<Props> = ({ audioUrl, position, color }) => {
13
+ const frame = useCurrentFrame();
14
+ const { fps, height } = useVideoConfig();
15
+ const audioData = useAudioData(audioUrl);
16
+
17
+ if (!audioData) {
18
+ return null;
19
+ }
20
+
21
+ // High resolution sampling
22
+ const visualizationValues = visualizeAudio({
23
+ fps,
24
+ frame,
25
+ audioData,
26
+ numberOfSamples: 512,
27
+ smoothing: true,
28
+ });
29
+
30
+ // 1. Spatial Smoothing (Moving Average)
31
+ const smoothPoly = (data: number[]) => {
32
+ return data.map((val, i, arr) => {
33
+ const prev = arr[i - 1] ?? val;
34
+ const next = arr[i + 1] ?? val;
35
+ const prev2 = arr[i - 2] ?? prev;
36
+ const next2 = arr[i + 2] ?? next;
37
+ return (prev2 + prev + val + next + next2) / 5;
38
+ });
39
+ };
40
+
41
+ const smoothedData = smoothPoly(visualizationValues);
42
+
43
+ // 2. Logarithmic Scale with Noise Gate
44
+ const getVisualValue = (val: number) => {
45
+ if (val < 0.015) return 0;
46
+ return Math.min(1, Math.log10(1 + val * 80) / 2.2);
47
+ };
48
+
49
+ const frequencyData = smoothedData;
50
+
51
+ // Position Logic
52
+ const bottomOffset = position === 'bottom' ? 80 :
53
+ position === 'mid-bottom' ? height * 0.25 :
54
+ position === 'center' ? height * 0.5 :
55
+ height - 100; // top
56
+
57
+ const containerStyle: React.CSSProperties = {
58
+ position: 'absolute',
59
+ bottom: position === 'top' ? undefined : bottomOffset,
60
+ top: position === 'top' ? 100 : undefined,
61
+ left: '5%',
62
+ width: '90%',
63
+ display: 'flex',
64
+ justifyContent: 'center',
65
+ alignItems: 'center',
66
+ zIndex: 10,
67
+ };
68
+
69
+ // WAVE STYLE ONLY
70
+ // Mirrored wave with high detail
71
+ const relevantFreqs = frequencyData.slice(0, 100);
72
+
73
+ return (
74
+ <div style={containerStyle}>
75
+ <div style={{ display: 'flex', alignItems: 'center', gap: '3px', width: '100%', justifyContent: 'center' }}>
76
+ {relevantFreqs.map((v: number, i: number) => {
77
+ const amplified = getVisualValue(v);
78
+ const h = Math.max(3, 50 + (amplified * 200));
79
+
80
+ return (
81
+ <div
82
+ key={i}
83
+ style={{
84
+ flex: 1,
85
+ maxWidth: '6px',
86
+ height: `${h}px`,
87
+ backgroundColor: color,
88
+ borderRadius: '3px',
89
+ opacity: 0.85,
90
+ transform: `scaleY(${i % 2 === 0 ? 1 : -0.7})`,
91
+ transition: 'height 0.1s ease-out',
92
+ }}
93
+ />
94
+ );
95
+ })}
96
+ </div>
97
+ </div>
98
+ );
99
+ };
remotion/MainComposition.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AbsoluteFill, Sequence, Series } from 'remotion';
2
+ import { z } from 'zod';
3
+ import { Scene } from './Scene';
4
+ import { ProjectSettings, SceneApi } from '../types';
5
+
6
+ export const MainCompositionSchema = z.object({
7
+ scenes: z.array(z.any()), // refined type below
8
+ settings: z.any()
9
+ });
10
+
11
+ type Props = {
12
+ scenes: SceneApi[];
13
+ settings: ProjectSettings;
14
+ }
15
+
16
+ export const MainComposition: React.FC<Props> = ({ scenes, settings }) => {
17
+ if (!scenes || scenes.length === 0) {
18
+ return (
19
+ <AbsoluteFill className="bg-black flex items-center justify-center">
20
+ <h1 className="text-white text-4xl">Waiting for scenes...</h1>
21
+ </AbsoluteFill>
22
+ )
23
+ }
24
+
25
+ return (
26
+ <AbsoluteFill className="bg-black">
27
+ <Series>
28
+ {scenes.map((scene) => {
29
+ const durationInSeconds = scene.duration || 5;
30
+ const durationInFrames = Math.ceil(durationInSeconds * 30);
31
+
32
+ return (
33
+ <Series.Sequence key={scene.id} durationInFrames={durationInFrames}>
34
+ <Scene scene={scene} settings={settings} />
35
+ </Series.Sequence>
36
+ )
37
+ })}
38
+ </Series>
39
+ </AbsoluteFill>
40
+ );
41
+ };
remotion/Root.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Composition } from 'remotion';
2
+ import { MainComposition } from './MainComposition';
3
+
4
+ export const RemotionRoot: React.FC = () => {
5
+ return (
6
+ <>
7
+ <Composition
8
+ id="Main"
9
+ component={MainComposition}
10
+ durationInFrames={150}
11
+ fps={30}
12
+ width={1920}
13
+ height={1080}
14
+ defaultProps={{
15
+ scenes: [],
16
+ settings: {
17
+ aspectRatio: '16:9' as const,
18
+ visualStyle: 'zen',
19
+ imageModel: 'fal',
20
+ audioVoice: 'English_ManWithDeepVoice',
21
+ disclaimerEnabled: false,
22
+ captions: {
23
+ enabled: true,
24
+ position: 'bottom' as const,
25
+ font: 'helvetica',
26
+ fontSize: 'medium' as const,
27
+ animation: 'typewriter' as const,
28
+ strokeWidth: 'medium' as const,
29
+ },
30
+ transitions: {
31
+ mode: 'random',
32
+ type: 'fadein',
33
+ },
34
+ },
35
+ }}
36
+ calculateMetadata={({ props }) => {
37
+ // Calculate total duration based on individual scene frames to prevent rounding errors
38
+ const totalFrames = props.scenes.reduce((acc, scene) => {
39
+ return acc + Math.ceil((scene.duration || 5) * 30);
40
+ }, 0);
41
+
42
+ // Calculate dimensions based on aspect ratio
43
+ const isPortrait = props.settings.aspectRatio === '9:16';
44
+ const width = isPortrait ? 1080 : 1920;
45
+ const height = isPortrait ? 1920 : 1080;
46
+
47
+ return {
48
+ durationInFrames: totalFrames || 150,
49
+ width,
50
+ height,
51
+ };
52
+ }}
53
+ />
54
+ </>
55
+ );
56
+ };
remotion/Scene.tsx ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AbsoluteFill, Img, Video, useCurrentFrame, useVideoConfig, interpolate, Easing, Audio } from 'remotion';
2
+ import { SceneApi, ProjectSettings } from './types';
3
+ import { AudioWave } from './AudioWave';
4
+
5
+ type Props = {
6
+ scene: SceneApi;
7
+ settings: ProjectSettings;
8
+ };
9
+
10
+ export const Scene: React.FC<Props> = ({ scene, settings }) => {
11
+ const frame = useCurrentFrame();
12
+ const { fps, width, height } = useVideoConfig();
13
+ const durationFrames = Math.ceil((scene.duration || 5) * fps);
14
+
15
+ // Transition duration (in frames) - 0.3 seconds
16
+ const transitionDuration = fps * 0.3;
17
+
18
+ // Calculate transition opacity/effects
19
+ const getTransitionStyle = () => {
20
+ if (frame >= transitionDuration) return {};
21
+
22
+ const progress = frame / transitionDuration;
23
+
24
+ switch (settings.transitions.type) {
25
+ case 'fadein':
26
+ return { opacity: interpolate(frame, [0, transitionDuration], [0, 1]) };
27
+
28
+ case 'crossfade':
29
+ return { opacity: interpolate(frame, [0, transitionDuration], [0, 1]) };
30
+
31
+ case 'white_flash':
32
+ const whiteFlash = interpolate(frame, [0, transitionDuration * 0.5, transitionDuration], [1, 0, 0], { extrapolateRight: 'clamp' });
33
+ return {
34
+ opacity: 1,
35
+ filter: `brightness(${1 + whiteFlash * 3})`
36
+ };
37
+
38
+ case 'camera_flash':
39
+ const cameraBright = interpolate(frame, [0, transitionDuration * 0.3, transitionDuration], [2, 1, 1], { extrapolateRight: 'clamp' });
40
+ return {
41
+ filter: `brightness(${cameraBright}) contrast(${interpolate(frame, [0, transitionDuration], [1.5, 1])})`
42
+ };
43
+
44
+ case 'none':
45
+ default:
46
+ return {};
47
+ }
48
+ };
49
+
50
+ // Ken Burns Effect (Slow Zoom)
51
+ const scale = interpolate(
52
+ frame,
53
+ [0, durationFrames],
54
+ [1, 1.15], // Zoom from 100% to 115%
55
+ { easing: Easing.bezier(0.25, 1, 0.5, 1) }
56
+ );
57
+
58
+ // Slight Pan (Random direction simulation using order_index as seed logic)
59
+ // Simple pan right for now
60
+ const translateX = interpolate(
61
+ frame,
62
+ [0, durationFrames],
63
+ [0, -20]
64
+ );
65
+
66
+ return (
67
+ <AbsoluteFill style={{ overflow: 'hidden', ...getTransitionStyle() }}>
68
+ {/* Background Image with Ken Burns */}
69
+ {scene.image_url ? (
70
+ <AbsoluteFill style={{ transform: `scale(${scale}) translateX(${translateX}px)` }}>
71
+ {(scene.media_type === 'video' || scene.image_url.includes('.mp4')) ? (
72
+ <Video
73
+ src={scene.image_url}
74
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
75
+ muted={true}
76
+ // Loop video if shorter than scene duration
77
+ loop
78
+ />
79
+ ) : (
80
+ <Img
81
+ src={scene.image_url}
82
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
83
+ />
84
+ )}
85
+ </AbsoluteFill>
86
+ ) : (
87
+ <AbsoluteFill style={{
88
+ backgroundColor: '#111827', // gray-900
89
+ display: 'flex',
90
+ alignItems: 'center',
91
+ justifyContent: 'center'
92
+ }}>
93
+ <span style={{ color: 'white' }}>Generating Image...</span>
94
+ </AbsoluteFill>
95
+ )}
96
+
97
+ {/* Audio */}
98
+ {scene.audio_url && (
99
+ <Audio
100
+ src={scene.audio_url}
101
+ volume={1}
102
+ startFrom={0}
103
+ />
104
+ )}
105
+
106
+ {/* Audio Wave Visualization */}
107
+ {settings.audioWave?.enabled && scene.audio_url && (
108
+ <AudioWave
109
+ audioUrl={scene.audio_url}
110
+ style={settings.audioWave.style}
111
+ position={settings.audioWave.position}
112
+ color={settings.audioWave.color}
113
+ />
114
+ )}
115
+
116
+ {/* Captions */}
117
+ {settings.captions.enabled && (
118
+ <AbsoluteFill
119
+ style={{
120
+ display: 'flex',
121
+ flexDirection: 'column',
122
+ alignItems: 'center',
123
+ pointerEvents: 'none',
124
+ paddingLeft: 32,
125
+ paddingRight: 32,
126
+ // Dynamic alignment
127
+ justifyContent: settings.captions.position === 'top' ? 'flex-start' :
128
+ settings.captions.position === 'center' ? 'center' :
129
+ 'flex-end',
130
+ paddingTop: settings.captions.position === 'top' ? 64 : 0,
131
+ paddingBottom: settings.captions.position === 'mid-bottom' ? 128 :
132
+ settings.captions.position === 'bottom' ? 64 : 0,
133
+ width: '100%',
134
+ height: '100%'
135
+ }}
136
+ >
137
+ <div
138
+ style={{
139
+ color: 'white',
140
+ textAlign: 'center',
141
+ maxWidth: '85%',
142
+ lineHeight: 1.25,
143
+ fontFamily: settings.captions.font === 'serif' ? 'serif' :
144
+ settings.captions.font === 'brush' ? 'Brush Script MT, cursive' : 'sans-serif',
145
+
146
+ // Dynamic font size
147
+ fontSize: (() => {
148
+ const sizeMap = {
149
+ small: height > width ? (width < 500 ? 24 : 36) : (width < 1000 ? 32 : 48),
150
+ medium: height > width ? (width < 500 ? 32 : 48) : (width < 1000 ? 42 : 64),
151
+ large: height > width ? (width < 500 ? 40 : 60) : (width < 1000 ? 52 : 80),
152
+ xlarge: height > width ? (width < 500 ? 48 : 72) : (width < 1000 ? 64 : 96)
153
+ };
154
+ return sizeMap[settings.captions.fontSize || 'medium'];
155
+ })(),
156
+
157
+ fontWeight: settings.captions.strokeWidth === 'bold' ? 'bold' : 'normal',
158
+
159
+ textShadow: (() => {
160
+ const strokeMap = {
161
+ thin: '2px 2px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000',
162
+ medium: '3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000',
163
+ thick: '4px 4px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000, 2px 2px 0 #000',
164
+ bold: '5px 5px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000, 2px 2px 0 #000, 0 0 10px #000'
165
+ };
166
+ return strokeMap[settings.captions.strokeWidth || 'medium'];
167
+ })(),
168
+
169
+ // Animation-specific styles
170
+ ...(settings.captions.animation === 'fade-in' ? {
171
+ opacity: interpolate(frame, [0, durationFrames * 0.3], [0, 1], { extrapolateRight: 'clamp' })
172
+ } : {}),
173
+ ...(settings.captions.animation === 'slide-up' ? {
174
+ transform: `translateY(${interpolate(frame, [0, durationFrames * 0.3], [50, 0], { extrapolateRight: 'clamp' })}px)`,
175
+ opacity: interpolate(frame, [0, durationFrames * 0.3], [0, 1], { extrapolateRight: 'clamp' })
176
+ } : {}),
177
+ ...(settings.captions.animation === 'bounce' ? {
178
+ transform: `scale(${interpolate(
179
+ frame,
180
+ [0, durationFrames * 0.15, durationFrames * 0.3],
181
+ [0.5, 1.1, 1],
182
+ { extrapolateRight: 'clamp', easing: Easing.bounce }
183
+ )})`,
184
+ opacity: frame < durationFrames * 0.3 ? interpolate(frame, [0, durationFrames * 0.15], [0, 1], { extrapolateRight: 'clamp' }) : 1
185
+ } : {})
186
+ }}
187
+ >
188
+ {(() => {
189
+ const animation = settings.captions.animation || 'typewriter';
190
+
191
+ // Typewriter Effect
192
+ if (animation === 'typewriter') {
193
+ const chars = scene.text.length;
194
+ const progress = interpolate(frame, [0, durationFrames * 0.8], [0, chars]);
195
+ const visibleChars = Math.floor(progress);
196
+ return scene.text.slice(0, visibleChars);
197
+ }
198
+
199
+ // All other animations show full text
200
+ return scene.text;
201
+ })()}
202
+ </div>
203
+ </AbsoluteFill>
204
+ )}
205
+ </AbsoluteFill>
206
+ );
207
+ };
remotion/index.tsx ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import { registerRoot } from 'remotion';
2
+ import { RemotionRoot } from './Root';
3
+
4
+ registerRoot(RemotionRoot);
remotion/types.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface SceneApi {
2
+ id: string;
3
+ text: string;
4
+ duration: number;
5
+ image_url: string | null;
6
+ audio_url: string | null;
7
+ status: 'pending' | 'ready' | 'error';
8
+ order_index: number;
9
+ media_type?: 'image' | 'video';
10
+ }
11
+
12
+ export interface ProjectSettings {
13
+ aspectRatio: '16:9' | '9:16';
14
+ visualStyle: string;
15
+ imageModel: string;
16
+ audioVoice: string;
17
+ disclaimerEnabled: boolean;
18
+ captions: {
19
+ enabled: boolean;
20
+ position: 'top' | 'center' | 'bottom' | 'mid-bottom';
21
+ font: string;
22
+ fontSize: 'small' | 'medium' | 'large' | 'xlarge';
23
+ animation: 'typewriter' | 'fade-in' | 'slide-up' | 'bounce';
24
+ strokeWidth: 'thin' | 'medium' | 'thick' | 'bold';
25
+ };
26
+ audioWave: {
27
+ enabled: boolean;
28
+ position: 'bottom' | 'center' | 'top' | 'mid-bottom';
29
+ style: 'bars' | 'wave' | 'round';
30
+ color: string;
31
+ };
32
+ transitions: {
33
+ mode: string;
34
+ type: string;
35
+ };
36
+ }
server.js ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { bundle } from '@remotion/bundler';
4
+ import { renderMedia, selectComposition } from '@remotion/renderer';
5
+ import { readFile, mkdir, rm } from 'fs/promises';
6
+ import { tmpdir } from 'os';
7
+ import { join, dirname } from 'path';
8
+ import { randomUUID } from 'crypto';
9
+ import { fileURLToPath } from 'url';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ const app = express();
15
+ const PORT = process.env.PORT || 7860;
16
+
17
+ app.use(cors());
18
+ app.use(express.json({ limit: '50mb' }));
19
+
20
+ // Health check
21
+ app.get('/', (req, res) => {
22
+ res.json({ status: 'ok', service: 'FacelessFlowAI Video Renderer' });
23
+ });
24
+
25
+ // Render endpoint
26
+ app.post('/render', async (req, res) => {
27
+ const { projectId, scenes, settings } = req.body;
28
+
29
+ if (!projectId || !scenes || !settings) {
30
+ return res.status(400).json({ error: 'Missing projectId, scenes, or settings' });
31
+ }
32
+
33
+ // Initialize Supabase Admin Client
34
+ const SUPABASE_URL = process.env.SUPABASE_URL;
35
+ const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
36
+
37
+ if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
38
+ console.error('[Config] Missing Supabase credentials');
39
+ return res.status(500).json({ error: 'Renderer configuration error' });
40
+ }
41
+
42
+ const { createClient } = await import('@supabase/supabase-js');
43
+ const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
44
+
45
+ // 1. Respond immediately
46
+ res.json({ success: true, message: 'Rendering started in background' });
47
+
48
+ // 2. Start Background Process
49
+ (async () => {
50
+ try {
51
+ console.log(`[Render] Starting background render for project ${projectId}`);
52
+
53
+ const bundleLocation = await bundle({
54
+ entryPoint: join(__dirname, 'remotion', 'index.tsx'),
55
+ webpackOverride: (config) => config,
56
+ });
57
+
58
+ const composition = await selectComposition({
59
+ serveUrl: bundleLocation,
60
+ id: 'Main',
61
+ inputProps: { scenes, settings },
62
+ chromiumOptions: { executablePath: process.env.CHROME_BIN || '/usr/bin/chromium' },
63
+ });
64
+
65
+ const tempDir = join(tmpdir(), `remotion-${randomUUID()}`);
66
+ await mkdir(tempDir, { recursive: true });
67
+ const outputPath = join(tempDir, 'output.mp4');
68
+
69
+ const os = await import('os');
70
+ const cpuCount = os.cpus().length;
71
+ console.log(`[Render] Detected ${cpuCount} CPUs. Using all cores for rendering.`);
72
+
73
+ // HuggingFace Spaces (Free Tier) often reports host CPUs (e.g. 16) but enforces a 2-core limit.
74
+ // Remotion crashes if we request more threads than the system allows.
75
+ const safeConcurrency = Math.min(cpuCount, 2);
76
+ console.log(`[Render] Requesting concurrency: ${safeConcurrency} (Host has ${cpuCount})`);
77
+
78
+ await renderMedia({
79
+ composition,
80
+ serveUrl: bundleLocation,
81
+ codec: 'h264',
82
+ pixelFormat: 'yuv420p',
83
+ outputLocation: outputPath,
84
+ inputProps: { scenes, settings },
85
+ chromiumOptions: {
86
+ executablePath: process.env.CHROME_BIN || '/usr/bin/chromium',
87
+ enableMultiProcessRendering: true,
88
+ },
89
+ concurrency: safeConcurrency,
90
+ disallowParallelEncoding: false,
91
+ ffmpegOverride: {
92
+ customCommands: '-hwaccel cuda -hwaccel_output_format cuda'
93
+ }
94
+ });
95
+
96
+ console.log(`[Render] Render complete. Uploading to Supabase...`);
97
+
98
+ const videoBuffer = await readFile(outputPath);
99
+
100
+ // Upload to Supabase Storage
101
+ const fileName = `${projectId}/video-${Date.now()}.mp4`;
102
+ const { data: uploadData, error: uploadError } = await supabase
103
+ .storage
104
+ .from('projects') // Assuming 'projects' bucket exists
105
+ .upload(fileName, videoBuffer, {
106
+ contentType: 'video/mp4',
107
+ upsert: true
108
+ });
109
+
110
+ if (uploadError) throw new Error(`Upload failed: ${uploadError.message}`);
111
+
112
+ // Get Public URL
113
+ const { data: { publicUrl } } = supabase
114
+ .storage
115
+ .from('projects')
116
+ .getPublicUrl(fileName);
117
+
118
+ console.log(`[Render] Uploaded to ${publicUrl}`);
119
+
120
+ // Update Project in DB
121
+ const { error: dbError } = await supabase
122
+ .from('projects')
123
+ .update({
124
+ status: 'done',
125
+ video_url: publicUrl
126
+ })
127
+ .eq('id', projectId);
128
+
129
+ if (dbError) throw new Error(`DB Update failed: ${dbError.message}`);
130
+
131
+ console.log(`[Render] Project ${projectId} updated successfully`);
132
+
133
+ // Cleanup
134
+ await rm(tempDir, { recursive: true, force: true });
135
+
136
+ } catch (error) {
137
+ console.error(`[Render] Background Error for ${projectId}:`, error);
138
+ // Optionally update project status to 'error' if you had that status
139
+ }
140
+ })();
141
+ });
142
+
143
+ app.listen(PORT, () => {
144
+ console.log(`🎬 FacelessFlowAI Renderer running on port ${PORT}`);
145
+
146
+ // Debug: Check for Secrets on Startup
147
+ console.log('--- Environment Check ---');
148
+ console.log('SUPABASE_URL exists:', !!process.env.SUPABASE_URL);
149
+ console.log('SUPABASE_SERVICE_ROLE_KEY exists:', !!process.env.SUPABASE_SERVICE_ROLE_KEY);
150
+ console.log('Available Keys (starts with SUPA):', Object.keys(process.env).filter(k => k.startsWith('SUPA')));
151
+ console.log('-------------------------');
152
+ });