Upload 10 files
Browse files- Dockerfile +56 -0
- package-lock.json +0 -0
- package.json +23 -0
- remotion/AudioWave.tsx +99 -0
- remotion/MainComposition.tsx +41 -0
- remotion/Root.tsx +56 -0
- remotion/Scene.tsx +207 -0
- remotion/index.tsx +4 -0
- remotion/types.ts +36 -0
- server.js +152 -0
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 |
+
});
|