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:
2224
package-lock.json
generated
2224
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
10
remotion.config.ts
Normal 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
332
remotion/CutroomVideo.tsx
Normal 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
75
remotion/Root.tsx
Normal 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
8
remotion/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Remotion entry point
|
||||
*/
|
||||
|
||||
import { registerRoot } from 'remotion'
|
||||
import { RemotionRoot } from './Root'
|
||||
|
||||
registerRoot(RemotionRoot)
|
||||
18
remotion/tsconfig.json
Normal file
18
remotion/tsconfig.json
Normal 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
229
scripts/render-video.ts
Normal 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)
|
||||
})
|
||||
@@ -22,5 +22,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "remotion"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user