Add .gitignore to exclude all node packages and lock files

This commit is contained in:
Adolfo Reyna
2026-02-23 21:56:04 -05:00
parent faae96c9ed
commit dcc5c6c044
9747 changed files with 1555105 additions and 2 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "remotion-prompt-video",
"version": "1.0.0",
"type": "module",
"main": "render.mjs",
"scripts": {
"render": "node render.mjs",
"studio": "npx remotion studio src/index.ts"
},
"description": "Generate videos from text prompts using AI + Remotion",
"dependencies": {
"@remotion/bundler": "^4.0.422",
"@remotion/cli": "^4.0.422",
"@remotion/renderer": "^4.0.422",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"remotion": "^4.0.422",
"zod": "^3.22.3"
}
}

View File

@@ -0,0 +1,89 @@
/**
* render.mjs - Renders a video from a VideoScript JSON file.
*
* Usage: node render.mjs <script.json> [output.mp4]
*/
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function main() {
const args = process.argv.slice(2);
if (args.length < 1) {
console.error("Usage: node render.mjs <script.json> [output.mp4]");
process.exit(1);
}
const scriptPath = path.resolve(args[0]);
const outputPath = args[1]
? path.resolve(args[1])
: path.join(__dirname, "out", "video.mp4");
// Ensure output directory exists
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
console.log("Loading script:", scriptPath);
const script = JSON.parse(fs.readFileSync(scriptPath, "utf-8"));
console.log(`Video: "${script.title}"`);
console.log(` Scenes: ${script.scenes.length}`);
console.log(` Duration: ${script.totalDurationInSeconds}s`);
console.log(` Resolution: ${script.width}x${script.height}`);
console.log(` FPS: ${script.fps}`);
const totalFrames = script.totalDurationInSeconds * script.fps;
console.log("\nBundling Remotion project...");
const bundleLocation = await bundle({
entryPoint: path.resolve(__dirname, "src/index.ts"),
webpackOverride: (config) => config,
});
console.log("Selecting composition...");
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "PromptVideo",
inputProps: { script },
});
// Override composition metadata with our script values
composition.durationInFrames = totalFrames;
composition.fps = script.fps;
composition.width = script.width;
composition.height = script.height;
console.log(`Rendering ${totalFrames} frames to ${outputPath} ...`);
let lastProgress = 0;
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
outputLocation: outputPath,
inputProps: { script },
onProgress: ({ progress }) => {
const pct = Math.floor(progress * 100);
if (pct >= lastProgress + 10) {
lastProgress = pct;
process.stdout.write(` ${pct}%\r`);
}
},
});
const stats = fs.statSync(outputPath);
console.log(`\nDone! ${outputPath} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
}
main().catch((err) => {
console.error("Render failed:", err.message || err);
process.exit(1);
});

View File

@@ -0,0 +1,97 @@
import React from "react";
import { Composition } from "remotion";
import { VideoComposition } from "./components/Video";
import { VideoScript } from "./lib/types";
// Default script used for preview in the Remotion Studio
const defaultScript: VideoScript = {
title: "Sample Video",
description: "A sample video for preview",
fps: 30,
width: 1920,
height: 1080,
totalDurationInSeconds: 9,
scenes: [
{
id: 1,
title: "Welcome",
text: "Remotion Prompt Video",
subtitle: "Create videos from text prompts",
durationInSeconds: 3,
style: {
backgroundColor: "#1a1a2e",
textColor: "#ffffff",
fontSize: 72,
fontFamily: "Arial",
textAlign: "center",
},
animation: { entrance: "zoomIn", exit: "fadeOut" },
backgroundGradient: {
from: "#0f0c29",
to: "#302b63",
direction: "to bottom right",
},
icon: "🎬",
},
{
id: 2,
title: "Feature",
text: "AI-Powered Video Creation",
subtitle: "Just type a prompt and get a video",
durationInSeconds: 3,
style: {
backgroundColor: "#16213e",
textColor: "#e0e0e0",
fontSize: 64,
fontFamily: "Georgia",
textAlign: "center",
},
animation: { entrance: "slideUp", exit: "slideDown" },
backgroundGradient: {
from: "#0f3460",
to: "#533483",
direction: "to right",
},
icon: "✨",
},
{
id: 3,
title: "Outro",
text: "Get Started Today",
subtitle: "remotion-prompt-video",
durationInSeconds: 3,
style: {
backgroundColor: "#1a1a2e",
textColor: "#ffffff",
fontSize: 72,
fontFamily: "Arial",
textAlign: "center",
},
animation: { entrance: "fadeIn", exit: "fadeOut" },
backgroundGradient: {
from: "#302b63",
to: "#24243e",
direction: "to bottom",
},
icon: "🚀",
},
],
};
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="PromptVideo"
component={VideoComposition}
durationInFrames={defaultScript.totalDurationInSeconds * defaultScript.fps}
fps={defaultScript.fps}
width={defaultScript.width}
height={defaultScript.height}
defaultProps={{
script: defaultScript,
}}
/>
</>
);
};

View File

@@ -0,0 +1,259 @@
import React from "react";
import {
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
spring,
} from "remotion";
import { Scene as SceneType } from "../lib/types";
interface SceneProps {
scene: SceneType;
}
export const SceneComponent: React.FC<SceneProps> = ({ scene }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const totalFrames = scene.durationInSeconds * fps;
// Entrance animation (first 20% of scene)
const entranceDuration = Math.floor(totalFrames * 0.2);
// Exit animation (last 20% of scene)
const exitStart = totalFrames - Math.floor(totalFrames * 0.2);
// Calculate entrance opacity and transform
const getEntranceStyles = (): React.CSSProperties => {
const progress = Math.min(frame / entranceDuration, 1);
const springProgress = spring({
frame: Math.min(frame, entranceDuration),
fps,
config: { damping: 15, stiffness: 100, mass: 0.8 },
});
switch (scene.animation.entrance) {
case "fadeIn":
return { opacity: interpolate(frame, [0, entranceDuration], [0, 1], { extrapolateRight: "clamp" }) };
case "slideUp":
return {
opacity: interpolate(frame, [0, entranceDuration * 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateY(${interpolate(springProgress, [0, 1], [80, 0])}px)`,
};
case "slideLeft":
return {
opacity: interpolate(frame, [0, entranceDuration * 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateX(${interpolate(springProgress, [0, 1], [100, 0])}px)`,
};
case "slideRight":
return {
opacity: interpolate(frame, [0, entranceDuration * 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `translateX(${interpolate(springProgress, [0, 1], [-100, 0])}px)`,
};
case "zoomIn":
return {
opacity: interpolate(frame, [0, entranceDuration * 0.5], [0, 1], { extrapolateRight: "clamp" }),
transform: `scale(${interpolate(springProgress, [0, 1], [0.5, 1])})`,
};
case "typewriter":
return { opacity: 1 };
case "none":
default:
return { opacity: 1 };
}
};
// Calculate exit styles
const getExitStyles = (): React.CSSProperties => {
if (frame < exitStart) return {};
const exitProgress = (frame - exitStart) / (totalFrames - exitStart);
switch (scene.animation.exit) {
case "fadeOut":
return { opacity: interpolate(exitProgress, [0, 1], [1, 0]) };
case "slideDown":
return {
opacity: interpolate(exitProgress, [0.5, 1], [1, 0], { extrapolateLeft: "clamp" }),
transform: `translateY(${interpolate(exitProgress, [0, 1], [0, 80])}px)`,
};
case "slideLeft":
return {
opacity: interpolate(exitProgress, [0.5, 1], [1, 0], { extrapolateLeft: "clamp" }),
transform: `translateX(${interpolate(exitProgress, [0, 1], [0, -100])}px)`,
};
case "slideRight":
return {
opacity: interpolate(exitProgress, [0.5, 1], [1, 0], { extrapolateLeft: "clamp" }),
transform: `translateX(${interpolate(exitProgress, [0, 1], [0, 100])}px)`,
};
case "zoomOut":
return {
opacity: interpolate(exitProgress, [0.5, 1], [1, 0], { extrapolateLeft: "clamp" }),
transform: `scale(${interpolate(exitProgress, [0, 1], [1, 1.5])})`,
};
case "none":
default:
return {};
}
};
// Typewriter effect for text
const getDisplayText = (text: string): string => {
if (scene.animation.entrance !== "typewriter") return text;
const charsToShow = Math.floor(
interpolate(frame, [0, entranceDuration * 1.5], [0, text.length], {
extrapolateRight: "clamp",
})
);
return text.slice(0, charsToShow);
};
// Background
const backgroundStyle: React.CSSProperties = scene.backgroundGradient
? {
background: `linear-gradient(${scene.backgroundGradient.direction}, ${scene.backgroundGradient.from}, ${scene.backgroundGradient.to})`,
}
: {
backgroundColor: scene.style.backgroundColor,
};
const entranceStyles = getEntranceStyles();
const exitStyles = getExitStyles();
// Merge entrance and exit
const combinedStyles: React.CSSProperties = {
...entranceStyles,
...(frame >= exitStart ? exitStyles : {}),
};
// Subtle background animation
const bgScale = interpolate(frame, [0, totalFrames], [1, 1.05], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
...backgroundStyle,
transform: `scale(${bgScale})`,
display: "flex",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
}}
>
{/* Decorative elements */}
<div
style={{
position: "absolute",
top: "-20%",
right: "-10%",
width: "40%",
height: "40%",
borderRadius: "50%",
background: "rgba(255,255,255,0.05)",
filter: "blur(60px)",
}}
/>
<div
style={{
position: "absolute",
bottom: "-15%",
left: "-10%",
width: "35%",
height: "35%",
borderRadius: "50%",
background: "rgba(255,255,255,0.03)",
filter: "blur(40px)",
}}
/>
{/* Content */}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: scene.style.textAlign === "left" ? "flex-start" : scene.style.textAlign === "right" ? "flex-end" : "center",
justifyContent: "center",
padding: "80px",
maxWidth: "85%",
textAlign: scene.style.textAlign,
...combinedStyles,
}}
>
{/* Icon */}
{scene.icon && (
<div
style={{
fontSize: scene.style.fontSize * 1.2,
marginBottom: "20px",
filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.3))",
}}
>
{scene.icon}
</div>
)}
{/* Title (small label above main text) */}
{scene.title && scene.title !== scene.text && (
<div
style={{
color: scene.style.textColor,
fontSize: Math.max(scene.style.fontSize * 0.35, 18),
fontFamily: scene.style.fontFamily,
fontWeight: 600,
letterSpacing: "3px",
textTransform: "uppercase",
marginBottom: "16px",
opacity: 0.7,
}}
>
{scene.title}
</div>
)}
{/* Main text */}
<div
style={{
color: scene.style.textColor,
fontSize: scene.style.fontSize,
fontFamily: scene.style.fontFamily,
fontWeight: 700,
lineHeight: 1.2,
textShadow: "0 2px 20px rgba(0,0,0,0.3)",
letterSpacing: "-0.5px",
}}
>
{getDisplayText(scene.text)}
{scene.animation.entrance === "typewriter" && frame < entranceDuration * 1.5 && (
<span
style={{
opacity: Math.sin(frame * 0.3) > 0 ? 1 : 0,
marginLeft: "2px",
}}
>
|
</span>
)}
</div>
{/* Subtitle */}
{scene.subtitle && (
<div
style={{
color: scene.style.textColor,
fontSize: Math.max(scene.style.fontSize * 0.45, 20),
fontFamily: scene.style.fontFamily,
fontWeight: 400,
marginTop: "20px",
opacity: 0.8,
lineHeight: 1.5,
}}
>
{getDisplayText(scene.subtitle)}
</div>
)}
</div>
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,33 @@
import React from "react";
import { AbsoluteFill, Sequence } from "remotion";
import { VideoScript } from "../lib/types";
import { SceneComponent } from "./Scene";
interface VideoProps {
script: VideoScript;
}
export const VideoComposition: React.FC<VideoProps> = ({ script }) => {
let currentFrame = 0;
return (
<AbsoluteFill style={{ backgroundColor: "#000" }}>
{script.scenes.map((scene) => {
const startFrame = currentFrame;
const durationInFrames = scene.durationInSeconds * script.fps;
currentFrame += durationInFrames;
return (
<Sequence
key={scene.id}
from={startFrame}
durationInFrames={durationInFrames}
name={scene.title || `Scene ${scene.id}`}
>
<SceneComponent scene={scene} />
</Sequence>
);
})}
</AbsoluteFill>
);
};

View File

@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);

View File

@@ -0,0 +1,50 @@
/**
* Types for the prompt-to-video pipeline.
* The LLM generates a VideoScript, which Remotion renders into a video.
*/
export interface SceneStyle {
backgroundColor: string;
textColor: string;
fontSize: number;
fontFamily: string;
textAlign: "left" | "center" | "right";
}
export interface SceneAnimation {
entrance: "fadeIn" | "slideUp" | "slideLeft" | "slideRight" | "zoomIn" | "typewriter" | "none";
exit: "fadeOut" | "slideDown" | "slideLeft" | "slideRight" | "zoomOut" | "none";
}
export interface Scene {
id: number;
title: string;
text: string;
subtitle?: string;
durationInSeconds: number;
style: SceneStyle;
animation: SceneAnimation;
backgroundGradient?: {
from: string;
to: string;
direction: "to right" | "to bottom" | "to bottom right" | "to top right";
};
icon?: string; // emoji icon
}
export interface VideoScript {
title: string;
description: string;
scenes: Scene[];
fps: number;
width: number;
height: number;
totalDurationInSeconds: number;
}
export interface GenerateRequest {
prompt: string;
style?: "modern" | "minimal" | "bold" | "playful" | "corporate";
aspectRatio?: "16:9" | "9:16" | "1:1";
maxScenes?: number;
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": ".",
"resolveJsonModule": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "out"]
}