feat: add Remotion video composition and render script

Implements #28

Remotion Setup:
- remotion/CutroomVideo.tsx: Main video component with:
  - Title card intro with animations
  - Content sections with captions
  - B-roll clip support
  - Voice + music audio tracks
  - CTA outro with branding
- remotion/Root.tsx: Composition registration
- Two compositions: vertical (1080x1920) and horizontal (1920x1080)

Render Script:
- scripts/render-video.ts: Takes pipeline output JSON, renders MP4
- Supports --input, --output, --horizontal flags
- Progress logging during render

Commands:
- npm run video:render — Render from pipeline output
- npm run video:preview — Preview in Remotion Studio

Config:
- remotion.config.ts: Remotion CLI config
- tsconfig.json: Exclude remotion from main typecheck (separate config)
This commit is contained in:
2026-02-01 21:43:43 -06:00
parent 4d4bc59124
commit fc08ee158c
9 changed files with 2872 additions and 33 deletions

2224
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,10 +16,14 @@
"db:generate": "prisma generate",
"postinstall": "prisma generate",
"deploy:token": "npx ts-node scripts/deploy-token.ts",
"pipeline:run": "npx ts-node scripts/run-pipeline.ts"
"pipeline:run": "npx ts-node scripts/run-pipeline.ts",
"video:render": "npx ts-node scripts/render-video.ts",
"video:preview": "npx remotion preview remotion/index.ts"
},
"dependencies": {
"@prisma/client": "^6.3.1",
"@remotion/cli": "^4.0.416",
"@remotion/renderer": "^4.0.416",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -28,6 +32,7 @@
"next": "14.2.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"remotion": "^4.0.416",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"

10
remotion.config.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Remotion Configuration
*
* Entry point for video rendering.
*/
import { Config } from '@remotion/cli/config'
Config.setVideoImageFormat('jpeg')
Config.setOverwriteOutput(true)

332
remotion/CutroomVideo.tsx Normal file
View File

@@ -0,0 +1,332 @@
/**
* Main Cutroom Video Component
*
* Renders a short-form video with:
* - Title card intro
* - Sections with captions
* - B-roll visuals
* - Voice + music audio tracks
* - CTA outro
*/
import React from 'react'
import {
AbsoluteFill,
Audio,
Img,
Sequence,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
} from 'remotion'
export interface ScriptSection {
heading: string
content: string
duration: number // in seconds
visualUrl?: string
}
export interface CutroomVideoProps {
title: string
script: {
hook: string
sections: ScriptSection[]
cta: string
}
voiceUrl?: string
musicUrl?: string
clips: Array<{
url: string
startTime: number
duration: number
}>
}
export const CutroomVideo: React.FC<CutroomVideoProps> = ({
title,
script,
voiceUrl,
musicUrl,
clips,
}) => {
const { fps, width, height, durationInFrames } = useVideoConfig()
const frame = useCurrentFrame()
// Calculate section timings
const introDuration = 3 * fps // 3 seconds for intro
const outroDuration = 3 * fps // 3 seconds for outro
const sectionFrames = script.sections.map(s => s.duration * fps)
const totalSectionFrames = sectionFrames.reduce((a, b) => a + b, 0)
return (
<AbsoluteFill style={{ backgroundColor: '#0a0a0a' }}>
{/* Background gradient */}
<AbsoluteFill
style={{
background: 'linear-gradient(180deg, #1a1a2e 0%, #0a0a0a 100%)',
}}
/>
{/* B-roll clips */}
{clips.map((clip, i) => (
<Sequence
key={i}
from={Math.floor(clip.startTime * fps)}
durationInFrames={Math.floor(clip.duration * fps)}
>
<AbsoluteFill style={{ opacity: 0.4 }}>
<Img
src={clip.url}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</AbsoluteFill>
</Sequence>
))}
{/* Intro Sequence */}
<Sequence durationInFrames={introDuration}>
<TitleCard title={title} hook={script.hook} />
</Sequence>
{/* Content Sections */}
{(() => {
let currentFrame = introDuration
return script.sections.map((section, i) => {
const from = currentFrame
const duration = sectionFrames[i]
currentFrame += duration
return (
<Sequence key={i} from={from} durationInFrames={duration}>
<ContentSection section={section} />
</Sequence>
)
})
})()}
{/* Outro Sequence */}
<Sequence from={durationInFrames - outroDuration}>
<OutroCard cta={script.cta} />
</Sequence>
{/* Voiceover Audio */}
{voiceUrl && (
<Audio src={voiceUrl} volume={1} />
)}
{/* Background Music */}
{musicUrl && (
<Audio src={musicUrl} volume={0.2} />
)}
</AbsoluteFill>
)
}
// Title Card Component
const TitleCard: React.FC<{ title: string; hook: string }> = ({ title, hook }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const titleOpacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' })
const titleY = interpolate(frame, [0, 15], [50, 0], { extrapolateRight: 'clamp' })
const hookOpacity = interpolate(frame, [20, 35], [0, 1], { extrapolateRight: 'clamp' })
const hookScale = spring({ frame: frame - 20, fps, config: { damping: 12 } })
return (
<AbsoluteFill
style={{
justifyContent: 'center',
alignItems: 'center',
padding: 40,
}}
>
<div
style={{
opacity: titleOpacity,
transform: `translateY(${titleY}px)`,
textAlign: 'center',
}}
>
<h1
style={{
color: '#fff',
fontSize: 64,
fontWeight: 'bold',
fontFamily: 'Inter, system-ui, sans-serif',
margin: 0,
textShadow: '0 4px 20px rgba(0,0,0,0.5)',
}}
>
{title}
</h1>
</div>
<div
style={{
opacity: hookOpacity,
transform: `scale(${hookScale})`,
marginTop: 40,
textAlign: 'center',
}}
>
<p
style={{
color: '#a0a0ff',
fontSize: 36,
fontFamily: 'Inter, system-ui, sans-serif',
margin: 0,
}}
>
{hook}
</p>
</div>
</AbsoluteFill>
)
}
// Content Section Component
const ContentSection: React.FC<{ section: ScriptSection }> = ({ section }) => {
const frame = useCurrentFrame()
const { fps, height } = useVideoConfig()
const headingOpacity = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: 'clamp' })
const contentOpacity = interpolate(frame, [10, 25], [0, 1], { extrapolateRight: 'clamp' })
return (
<AbsoluteFill
style={{
justifyContent: 'flex-end',
padding: 40,
paddingBottom: 120,
}}
>
{/* Section heading */}
<div
style={{
opacity: headingOpacity,
marginBottom: 20,
}}
>
<span
style={{
color: '#7b7bff',
fontSize: 28,
fontWeight: 600,
fontFamily: 'Inter, system-ui, sans-serif',
textTransform: 'uppercase',
letterSpacing: 2,
}}
>
{section.heading}
</span>
</div>
{/* Caption text */}
<div
style={{
opacity: contentOpacity,
background: 'rgba(0,0,0,0.7)',
padding: '20px 30px',
borderRadius: 12,
backdropFilter: 'blur(10px)',
}}
>
<p
style={{
color: '#fff',
fontSize: 42,
fontWeight: 500,
fontFamily: 'Inter, system-ui, sans-serif',
margin: 0,
lineHeight: 1.4,
textShadow: '0 2px 10px rgba(0,0,0,0.5)',
}}
>
{section.content}
</p>
</div>
</AbsoluteFill>
)
}
// Outro Card Component
const OutroCard: React.FC<{ cta: string }> = ({ cta }) => {
const frame = useCurrentFrame()
const { fps } = useVideoConfig()
const opacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' })
const scale = spring({ frame, fps, config: { damping: 10 } })
return (
<AbsoluteFill
style={{
justifyContent: 'center',
alignItems: 'center',
padding: 40,
}}
>
<div
style={{
opacity,
transform: `scale(${scale})`,
textAlign: 'center',
}}
>
{/* CTA */}
<p
style={{
color: '#fff',
fontSize: 48,
fontWeight: 'bold',
fontFamily: 'Inter, system-ui, sans-serif',
margin: 0,
}}
>
{cta}
</p>
{/* Follow button */}
<div
style={{
marginTop: 40,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '20px 60px',
borderRadius: 50,
display: 'inline-block',
}}
>
<span
style={{
color: '#fff',
fontSize: 32,
fontWeight: 600,
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
Follow @cutroom
</span>
</div>
{/* Cutroom branding */}
<p
style={{
color: '#666',
fontSize: 24,
fontFamily: 'Inter, system-ui, sans-serif',
marginTop: 60,
}}
>
Made with Cutroom 🎬
</p>
</div>
</AbsoluteFill>
)
}

75
remotion/Root.tsx Normal file
View File

@@ -0,0 +1,75 @@
/**
* Cutroom Video Composition
*
* Main video template for rendering short-form content.
*/
import React from 'react'
import { Composition } from 'remotion'
import { z } from 'zod'
import { CutroomVideo } from './CutroomVideo'
// Schema for props validation
const cutroomVideoSchema = z.object({
title: z.string(),
script: z.object({
hook: z.string(),
sections: z.array(z.object({
heading: z.string(),
content: z.string(),
duration: z.number(),
visualUrl: z.string().optional(),
})),
cta: z.string(),
}),
voiceUrl: z.string().optional(),
musicUrl: z.string().optional(),
clips: z.array(z.object({
url: z.string(),
startTime: z.number(),
duration: z.number(),
})),
})
const defaultProps: z.infer<typeof cutroomVideoSchema> = {
title: 'Sample Video',
script: {
hook: 'Did you know...',
sections: [
{ heading: 'Introduction', content: 'Welcome to this video', duration: 10 },
{ heading: 'Main Point', content: 'Here is the main content', duration: 40 },
{ heading: 'Conclusion', content: 'Thanks for watching', duration: 10 },
],
cta: 'Follow for more!',
},
voiceUrl: undefined,
musicUrl: undefined,
clips: [],
}
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="CutroomVideo"
component={CutroomVideo}
durationInFrames={1800}
fps={30}
width={1080}
height={1920}
schema={cutroomVideoSchema}
defaultProps={defaultProps}
/>
<Composition
id="CutroomVideoHorizontal"
component={CutroomVideo}
durationInFrames={1800}
fps={30}
width={1920}
height={1080}
schema={cutroomVideoSchema}
defaultProps={defaultProps}
/>
</>
)
}

8
remotion/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Remotion entry point
*/
import { registerRoot } from 'remotion'
import { RemotionRoot } from './Root'
registerRoot(RemotionRoot)

18
remotion/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": ["./**/*.ts", "./**/*.tsx"],
"exclude": ["node_modules"]
}

229
scripts/render-video.ts Normal file
View File

@@ -0,0 +1,229 @@
#!/usr/bin/env npx ts-node
/**
* Render Video Script
*
* Takes pipeline output and renders a video using Remotion.
*
* Usage:
* npx ts-node scripts/render-video.ts --input pipeline-output.json
* npx ts-node scripts/render-video.ts --input pipeline-output.json --output output.mp4
*/
import { bundle } from '@remotion/bundler'
import { renderMedia, selectComposition } from '@remotion/renderer'
import path from 'path'
import fs from 'fs'
// Pipeline output types
interface PipelineOutput {
topic: string
script?: {
hook: string
body: Array<{ heading: string; content: string; duration: number }>
cta: string
fullScript: string
}
voice?: {
audioUrl: string
duration: number
}
music?: {
audioUrl: string
duration: number
}
visual?: {
clips: Array<{
url: string
duration: number
startTime: number
description: string
}>
}
}
interface RenderOptions {
inputFile?: string
inputJson?: string
outputFile: string
compositionId: string
quality: number
}
function parseArgs(): RenderOptions {
const args = process.argv.slice(2)
const options: RenderOptions = {
outputFile: 'output.mp4',
compositionId: 'CutroomVideo',
quality: 80,
}
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--input':
case '-i':
options.inputFile = args[++i]
break
case '--json':
options.inputJson = args[++i]
break
case '--output':
case '-o':
options.outputFile = args[++i]
break
case '--composition':
case '-c':
options.compositionId = args[++i]
break
case '--quality':
case '-q':
options.quality = parseInt(args[++i], 10)
break
case '--horizontal':
options.compositionId = 'CutroomVideoHorizontal'
break
}
}
return options
}
function loadPipelineOutput(options: RenderOptions): PipelineOutput {
if (options.inputJson) {
return JSON.parse(options.inputJson)
}
if (options.inputFile) {
const content = fs.readFileSync(options.inputFile, 'utf-8')
return JSON.parse(content)
}
// Demo/test data
return {
topic: 'Demo Video',
script: {
hook: 'Did you know AI agents are changing everything?',
body: [
{
heading: 'What are AI Agents?',
content: 'AI agents are autonomous systems that can think, plan, and act.',
duration: 15,
},
{
heading: 'Why They Matter',
content: 'They can work 24/7, never get tired, and learn from experience.',
duration: 20,
},
{
heading: 'The Future',
content: 'Soon, agents will collaborate on complex tasks — like making this video!',
duration: 15,
},
],
cta: 'Follow for more AI insights!',
fullScript: '',
},
}
}
function pipelineToProps(pipeline: PipelineOutput) {
return {
title: pipeline.topic,
script: {
hook: pipeline.script?.hook || 'Welcome!',
sections: pipeline.script?.body.map(section => ({
heading: section.heading,
content: section.content,
duration: section.duration,
})) || [],
cta: pipeline.script?.cta || 'Thanks for watching!',
},
voiceUrl: pipeline.voice?.audioUrl,
musicUrl: pipeline.music?.audioUrl,
clips: pipeline.visual?.clips.map(clip => ({
url: clip.url,
startTime: clip.startTime,
duration: clip.duration,
})) || [],
}
}
async function main() {
const options = parseArgs()
const pipeline = loadPipelineOutput(options)
console.log('\n🎬 CUTROOM VIDEO RENDERER')
console.log('='.repeat(50))
console.log(`Topic: ${pipeline.topic}`)
console.log(`Composition: ${options.compositionId}`)
console.log(`Output: ${options.outputFile}`)
console.log('='.repeat(50) + '\n')
const startTime = Date.now()
// Bundle the Remotion project
console.log('📦 Bundling Remotion project...')
const bundleLocation = await bundle({
entryPoint: path.resolve(__dirname, '../remotion/index.ts'),
onProgress: (progress) => {
if (progress % 25 === 0) {
console.log(` ${progress}%`)
}
},
})
console.log('✅ Bundle complete\n')
// Get composition
console.log('🎥 Loading composition...')
const inputProps = pipelineToProps(pipeline)
// Calculate duration from script
const totalDuration = inputProps.script.sections.reduce(
(sum, s) => sum + s.duration,
6 // 3s intro + 3s outro
)
const composition = await selectComposition({
serveUrl: bundleLocation,
id: options.compositionId,
inputProps,
})
console.log(`✅ Composition loaded: ${composition.width}x${composition.height} @ ${composition.fps}fps\n`)
// Render the video
console.log('🎞️ Rendering video...')
let lastProgress = 0
await renderMedia({
composition: {
...composition,
durationInFrames: totalDuration * composition.fps,
},
serveUrl: bundleLocation,
codec: 'h264',
outputLocation: options.outputFile,
inputProps,
onProgress: ({ progress }) => {
const percent = Math.floor(progress * 100)
if (percent >= lastProgress + 10) {
console.log(` ${percent}%`)
lastProgress = percent
}
},
})
const duration = ((Date.now() - startTime) / 1000).toFixed(1)
console.log('\n' + '='.repeat(50))
console.log('✅ VIDEO RENDERED SUCCESSFULLY')
console.log('='.repeat(50))
console.log(`Output: ${options.outputFile}`)
console.log(`Duration: ${totalDuration}s`)
console.log(`Render time: ${duration}s`)
console.log('='.repeat(50) + '\n')
}
main().catch((error) => {
console.error('❌ Render failed:', error.message)
process.exit(1)
})

View File

@@ -22,5 +22,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "remotion"]
}