From 84f0b562e8422f470dab11a00fa9bdc04506f40b Mon Sep 17 00:00:00 2001 From: Miriad Date: Tue, 3 Mar 2026 14:05:46 +0000 Subject: [PATCH 1/2] feat(remotion): polish compositions for Cleo Abram-style visuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scene.tsx (150→326 lines): - Word-by-word kinetic text reveal with staggered timing - Scene progress bar with gradient fill - Scene number indicator (top-right) - Ken Burns zoom effect on B-roll video - Gradient overlay (darker at bottom for text readability) - Animated accent line before text appears - Improved text container with glow border HookScene.tsx (180→526 lines): - Dramatic staggered text reveal (2-3 lines, alternating directions) - Animated dot grid background with sinusoidal pulse - Glow sweep lines crossing screen before text - Brand name typing effect with blinking cursor - Animated underline after hook text - 18 floating particles with drift and wobble - Dynamic gradient rotation over scene duration CTAScene.tsx (202→491 lines): - Floating geometric shapes (hexagons, triangles, diamonds) - Gradient subscribe button with pulsing glow - Animated arrows pointing to subscribe button - Social links as colored cards (YouTube red, Twitter blue, etc.) - Confetti celebration burst (20 particles) - 'Don't miss out!' urgency text Compositions (MainVideo + ShortVideo): - Cross-fade transitions between scenes (0.5s overlap) - Clean timeline math replacing IIFE pattern - Proper fade-out on all scenes for smooth transitions Also: - Fix registerRoot() call in remotion/index.ts (required for bundling) - Add FFmpeg post-render compression utility (392 lines) - Two-pass or CRF encoding modes - Graceful degradation if FFmpeg unavailable - getVideoMetadata() helper via ffprobe --- lib/services/ffmpeg-compress.ts | 392 +++++++++++++++++++++ remotion/components/CTAScene.tsx | 396 ++++++++++++++++++--- remotion/components/HookScene.tsx | 497 +++++++++++++++++++++++---- remotion/components/Scene.tsx | 231 +++++++++++-- remotion/compositions/MainVideo.tsx | 93 +++-- remotion/compositions/ShortVideo.tsx | 84 +++-- remotion/index.ts | 5 + 7 files changed, 1483 insertions(+), 215 deletions(-) create mode 100644 lib/services/ffmpeg-compress.ts diff --git a/lib/services/ffmpeg-compress.ts b/lib/services/ffmpeg-compress.ts new file mode 100644 index 00000000..c79b5b81 --- /dev/null +++ b/lib/services/ffmpeg-compress.ts @@ -0,0 +1,392 @@ +/** + * FFmpeg post-render compression for video pipeline. + * + * Downloads the rendered video from Remotion Lambda's S3 output, + * compresses it with FFmpeg, and returns the compressed buffer. + * + * Uses two-pass encoding for optimal quality/size ratio. + */ + +import { execFileSync, execSync } from "child_process"; +import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CompressOptions { + /** Target bitrate for video (default: "2M" for 2 Mbps) */ + videoBitrate?: string; + /** Audio bitrate (default: "128k") */ + audioBitrate?: string; + /** CRF value for quality (default: 23, lower = better quality) */ + crf?: number; + /** Preset (default: "medium", options: ultrafast … veryslow) */ + preset?: string; + /** Max width — will scale down if larger, maintaining aspect ratio */ + maxWidth?: number; + /** Max height — will scale down if larger, maintaining aspect ratio */ + maxHeight?: number; +} + +export interface CompressResult { + /** Compressed video as Buffer */ + buffer: Buffer; + /** Original size in bytes */ + originalSize: number; + /** Compressed size in bytes */ + compressedSize: number; + /** Compression ratio (e.g., 0.6 means 60% of original) */ + ratio: number; + /** Duration of compression in ms */ + durationMs: number; +} + +export interface VideoMetadata { + /** Duration in seconds */ + duration: number; + /** Width in pixels */ + width: number; + /** Height in pixels */ + height: number; + /** Video codec (e.g. "h264") */ + videoCodec: string; + /** Audio codec (e.g. "aac") */ + audioCodec: string; + /** Overall bitrate in bits/s */ + bitrate: number; + /** File size in bytes */ + fileSize: number; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const LOG_PREFIX = "[FFMPEG]"; + +function log(...args: unknown[]) { + console.log(LOG_PREFIX, ...args); +} + +function warn(...args: unknown[]) { + console.warn(LOG_PREFIX, ...args); +} + +function isFfmpegAvailable(): boolean { + try { + execSync("ffmpeg -version", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function isFfprobeAvailable(): boolean { + try { + execSync("ffprobe -version", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * Download a URL to a Buffer. Works in Node 18+ (global fetch). + */ +async function downloadUrl(url: string): Promise { + log("Downloading video from URL …"); + const res = await fetch(url); + if (!res.ok) { + throw new Error(`${LOG_PREFIX} Failed to download video: ${res.status} ${res.statusText}`); + } + const arrayBuffer = await res.arrayBuffer(); + return Buffer.from(arrayBuffer); +} + +/** + * Create a temp directory and return helpers for managing temp files. + */ +function makeTempDir() { + const dir = mkdtempSync(join(tmpdir(), "ffmpeg-compress-")); + const inputPath = join(dir, "input.mp4"); + const outputPath = join(dir, "output.mp4"); + const passLogPrefix = join(dir, "ffmpeg2pass"); + + function cleanup() { + for (const f of [inputPath, outputPath]) { + try { + unlinkSync(f); + } catch { + /* ignore */ + } + } + // Two-pass log files + for (const suffix of ["", "-0.log", "-0.log.mbtree"]) { + try { + unlinkSync(passLogPrefix + suffix); + } catch { + /* ignore */ + } + } + try { + // Remove the temp directory itself + execSync(`rm -rf "${dir}"`, { stdio: "ignore" }); + } catch { + /* ignore */ + } + } + + return { dir, inputPath, outputPath, passLogPrefix, cleanup }; +} + +// --------------------------------------------------------------------------- +// Build FFmpeg arguments +// --------------------------------------------------------------------------- + +function buildScaleFilter(opts: CompressOptions): string | null { + if (!opts.maxWidth && !opts.maxHeight) return null; + + const w = opts.maxWidth ? `'min(${opts.maxWidth},iw)'` : "-2"; + const h = opts.maxHeight ? `'min(${opts.maxHeight},ih)'` : "-2"; + + // Ensure dimensions are divisible by 2 for H.264 + return `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2`; +} + +function buildFfmpegArgs( + inputPath: string, + outputPath: string, + opts: CompressOptions, + pass: 1 | 2 | "crf", + passLogPrefix?: string, +): string[] { + const args: string[] = ["-y", "-i", inputPath]; + + // Video codec + args.push("-c:v", "libx264"); + + // Preset + args.push("-preset", opts.preset || "medium"); + + // Scale filter + const scaleFilter = buildScaleFilter(opts); + if (scaleFilter) { + args.push("-vf", scaleFilter); + } + + if (pass === "crf") { + // Single-pass CRF mode + args.push("-crf", String(opts.crf ?? 23)); + // Audio + args.push("-c:a", "aac", "-b:a", opts.audioBitrate || "128k"); + // Faststart for web playback + args.push("-movflags", "+faststart"); + args.push(outputPath); + } else if (pass === 1) { + // Two-pass: first pass + args.push("-b:v", opts.videoBitrate || "2M"); + args.push("-pass", "1"); + args.push("-passlogfile", passLogPrefix!); + args.push("-an"); // No audio in first pass + args.push("-f", "null"); + args.push(process.platform === "win32" ? "NUL" : "/dev/null"); + } else { + // Two-pass: second pass + args.push("-b:v", opts.videoBitrate || "2M"); + args.push("-pass", "2"); + args.push("-passlogfile", passLogPrefix!); + args.push("-c:a", "aac", "-b:a", opts.audioBitrate || "128k"); + args.push("-movflags", "+faststart"); + args.push(outputPath); + } + + return args; +} + +// --------------------------------------------------------------------------- +// Main: compressVideo +// --------------------------------------------------------------------------- + +/** + * Compress a video using FFmpeg. + * + * @param input - A video URL (string) or a Buffer containing the video data. + * @param options - Compression options. + * @returns CompressResult with the compressed buffer and stats. + */ +export async function compressVideo( + input: string | Buffer, + options: CompressOptions = {}, +): Promise { + const startTime = Date.now(); + + // 1. Resolve input to a Buffer + let inputBuffer: Buffer; + if (typeof input === "string") { + inputBuffer = await downloadUrl(input); + } else { + inputBuffer = input; + } + + const originalSize = inputBuffer.length; + log(`Original video size: ${(originalSize / 1024 / 1024).toFixed(2)} MB`); + + // 2. Check FFmpeg availability + if (!isFfmpegAvailable()) { + warn("FFmpeg is not available — returning original video uncompressed."); + return { + buffer: inputBuffer, + originalSize, + compressedSize: originalSize, + ratio: 1, + durationMs: Date.now() - startTime, + }; + } + + // 3. Write input to temp file + const { inputPath, outputPath, passLogPrefix, cleanup } = makeTempDir(); + + try { + writeFileSync(inputPath, inputBuffer); + + // 4. Decide encoding strategy + const useTwoPass = !!options.videoBitrate; + + if (useTwoPass) { + log("Running two-pass encode …"); + + // Pass 1 + const pass1Args = buildFfmpegArgs(inputPath, outputPath, options, 1, passLogPrefix); + log("Pass 1:", "ffmpeg", pass1Args.join(" ")); + execFileSync("ffmpeg", pass1Args, { + stdio: ["ignore", "ignore", "pipe"], + timeout: 600_000, // 10 min + }); + + // Pass 2 + const pass2Args = buildFfmpegArgs(inputPath, outputPath, options, 2, passLogPrefix); + log("Pass 2:", "ffmpeg", pass2Args.join(" ")); + execFileSync("ffmpeg", pass2Args, { + stdio: ["ignore", "ignore", "pipe"], + timeout: 600_000, + }); + } else { + log("Running CRF encode …"); + const crfArgs = buildFfmpegArgs(inputPath, outputPath, options, "crf"); + log("ffmpeg", crfArgs.join(" ")); + execFileSync("ffmpeg", crfArgs, { + stdio: ["ignore", "ignore", "pipe"], + timeout: 600_000, + }); + } + + // 5. Read compressed output + const compressedBuffer = readFileSync(outputPath); + const compressedSize = compressedBuffer.length; + const ratio = compressedSize / originalSize; + const durationMs = Date.now() - startTime; + + log( + `Compression complete: ${(compressedSize / 1024 / 1024).toFixed(2)} MB ` + + `(${(ratio * 100).toFixed(1)}% of original) in ${(durationMs / 1000).toFixed(1)}s`, + ); + + return { + buffer: compressedBuffer, + originalSize, + compressedSize, + ratio, + durationMs, + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + warn(`Compression failed: ${errMsg} — returning original video.`); + return { + buffer: inputBuffer, + originalSize, + compressedSize: originalSize, + ratio: 1, + durationMs: Date.now() - startTime, + }; + } finally { + // 6. Clean up temp files + cleanup(); + } +} + +// --------------------------------------------------------------------------- +// getVideoMetadata +// --------------------------------------------------------------------------- + +/** + * Get video metadata using ffprobe. + * + * @param input - A file path, URL, or Buffer. + * @returns VideoMetadata with duration, resolution, codecs, etc. + */ +export async function getVideoMetadata(input: string | Buffer): Promise { + if (!isFfprobeAvailable()) { + throw new Error(`${LOG_PREFIX} ffprobe is not available`); + } + + let filePath: string; + let cleanupFn: (() => void) | null = null; + + if (Buffer.isBuffer(input)) { + const { inputPath, cleanup } = makeTempDir(); + writeFileSync(inputPath, input); + filePath = inputPath; + cleanupFn = cleanup; + } else if (input.startsWith("http://") || input.startsWith("https://")) { + // ffprobe can read URLs directly, but downloading is more reliable + const buf = await downloadUrl(input); + const { inputPath, cleanup } = makeTempDir(); + writeFileSync(inputPath, buf); + filePath = inputPath; + cleanupFn = cleanup; + } else { + filePath = input; + } + + try { + const probeArgs = [ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + filePath, + ]; + + const result = execFileSync("ffprobe", probeArgs, { + encoding: "utf-8", + timeout: 30_000, + }); + + const data = JSON.parse(result); + + const videoStream = data.streams?.find( + (s: { codec_type: string }) => s.codec_type === "video", + ); + const audioStream = data.streams?.find( + (s: { codec_type: string }) => s.codec_type === "audio", + ); + const format = data.format || {}; + + return { + duration: parseFloat(format.duration || "0"), + width: videoStream?.width || 0, + height: videoStream?.height || 0, + videoCodec: videoStream?.codec_name || "unknown", + audioCodec: audioStream?.codec_name || "unknown", + bitrate: parseInt(format.bit_rate || "0", 10), + fileSize: parseInt(format.size || "0", 10), + }; + } finally { + if (cleanupFn) cleanupFn(); + } +} diff --git a/remotion/components/CTAScene.tsx b/remotion/components/CTAScene.tsx index 32bdbaee..405abd9f 100644 --- a/remotion/components/CTAScene.tsx +++ b/remotion/components/CTAScene.tsx @@ -9,6 +9,113 @@ import { import type { CTASceneProps } from "../types"; import { ANIMATION, BRAND, COLORS, FONT_SIZES } from "../constants"; +// --- Geometric background shapes --- + +interface GeoShape { + type: "triangle" | "hexagon" | "diamond"; + x: number; // % from left + y: number; // % from top + size: number; + rotationSpeed: number; // degrees per frame + driftX: number; // px per frame + driftY: number; // px per frame + opacity: number; + color: string; +} + +const GEO_SHAPES: GeoShape[] = [ + { type: "hexagon", x: 10, y: 15, size: 80, rotationSpeed: 0.4, driftX: 0.15, driftY: -0.1, opacity: 0.08, color: COLORS.secondary }, + { type: "triangle", x: 85, y: 20, size: 60, rotationSpeed: -0.6, driftX: -0.1, driftY: 0.12, opacity: 0.06, color: COLORS.accent }, + { type: "diamond", x: 75, y: 75, size: 50, rotationSpeed: 0.5, driftX: -0.08, driftY: -0.15, opacity: 0.07, color: COLORS.primary }, + { type: "hexagon", x: 20, y: 80, size: 70, rotationSpeed: -0.3, driftX: 0.12, driftY: -0.08, opacity: 0.06, color: COLORS.ctaGreen }, + { type: "triangle", x: 50, y: 10, size: 45, rotationSpeed: 0.7, driftX: 0.05, driftY: 0.1, opacity: 0.05, color: COLORS.secondary }, + { type: "diamond", x: 40, y: 60, size: 55, rotationSpeed: -0.45, driftX: -0.06, driftY: -0.12, opacity: 0.06, color: COLORS.accent }, +]; + +const hexagonPath = (s: number) => { + const r = s / 2; + const points = Array.from({ length: 6 }, (_, i) => { + const angle = (Math.PI / 3) * i - Math.PI / 6; + return `${r + r * Math.cos(angle)},${r + r * Math.sin(angle)}`; + }); + return `M${points.join("L")}Z`; +}; + +const trianglePath = (s: number) => + `M${s / 2},0 L${s},${s} L0,${s} Z`; + +const diamondPath = (s: number) => + `M${s / 2},0 L${s},${s / 2} L${s / 2},${s} L0,${s / 2} Z`; + +const GeometricShape: React.FC<{ shape: GeoShape; frame: number }> = ({ + shape, + frame, +}) => { + const rotation = frame * shape.rotationSpeed; + const offsetX = frame * shape.driftX; + const offsetY = frame * shape.driftY; + + let pathD: string; + switch (shape.type) { + case "hexagon": + pathD = hexagonPath(shape.size); + break; + case "triangle": + pathD = trianglePath(shape.size); + break; + case "diamond": + pathD = diamondPath(shape.size); + break; + } + + return ( +
+ + + +
+ ); +}; + +// --- Confetti particle --- + +interface ConfettiParticle { + angle: number; // radians + speed: number; + size: number; + color: string; + shape: "square" | "circle"; + rotationSpeed: number; +} + +const CONFETTI_PARTICLES: ConfettiParticle[] = Array.from({ length: 20 }, (_, i) => ({ + angle: (Math.PI * 2 * i) / 20 + (i % 3) * 0.2, + speed: 3 + (i % 5) * 1.5, + size: 6 + (i % 4) * 3, + color: [COLORS.accent, COLORS.secondary, COLORS.ctaGreen, "#FF0000", COLORS.primary][i % 5], + shape: i % 2 === 0 ? "square" : "circle", + rotationSpeed: (i % 2 === 0 ? 1 : -1) * (2 + (i % 3)), +})); + +// --- Social link platform colors --- + +const SOCIAL_COLORS: Record = { + YouTube: "#FF0000", + Twitter: "#1DA1F2", + Discord: "#7C3AED", + Web: "#10B981", +}; + +// --- Main Component --- + export const CTAScene: React.FC = ({ cta, durationInFrames, @@ -18,7 +125,7 @@ export const CTAScene: React.FC = ({ const { fps } = useVideoConfig(); const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; - // --- Fade in --- + // --- Fade in / out --- const fadeIn = interpolate( frame, [0, ANIMATION.fadeIn], @@ -26,6 +133,15 @@ export const CTAScene: React.FC = ({ { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); + const fadeOut = interpolate( + frame, + [durationInFrames - ANIMATION.fadeOut, durationInFrames], + [1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + const opacity = fadeIn * fadeOut; + // --- CTA text spring --- const ctaSpring = spring({ frame: frame - 10, @@ -43,7 +159,67 @@ export const CTAScene: React.FC = ({ config: { damping: 10, mass: 0.4, stiffness: 100 }, }); - // --- Social links stagger --- + // Glow pulse (continuous) + const glowPulse = interpolate( + frame % 40, + [0, 20, 40], + [0.3, 0.8, 0.3], + { extrapolateRight: "clamp" } + ); + + // Button scale bounce + const buttonBounce = spring({ + frame: frame - 40, + fps, + config: { damping: 8, mass: 0.3, stiffness: 150 }, + }); + + const buttonPulse = interpolate( + frame % 30, + [0, 15, 30], + [1, 1.06, 1], + { extrapolateRight: "clamp" } + ); + + const buttonScale = buttonSpring * buttonPulse * interpolate(buttonBounce, [0, 0.5, 1], [0.9, 1.12, 1]); + + // --- Arrow animation (bouncing toward button) --- + const arrowSpring = spring({ + frame: frame - 50, + fps, + config: { damping: 14, mass: 0.5, stiffness: 80 }, + }); + + const arrowBounce = interpolate( + frame % 24, + [0, 12, 24], + [0, -12, 0], + { extrapolateRight: "clamp" } + ); + + // --- "Don't miss out!" urgency text --- + const urgencySpring = spring({ + frame: frame - 70, + fps, + config: { damping: 14, mass: 0.6, stiffness: 80 }, + }); + + // --- Confetti burst --- + const confettiProgress = interpolate( + frame, + [5, 45], + [0, 1], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + const confettiOpacity = interpolate( + frame, + [5, 15, 35, 45], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + // --- Social links --- const socialLinks = [ { label: "YouTube", value: BRAND.youtube }, { label: "Twitter", value: BRAND.twitter }, @@ -51,23 +227,63 @@ export const CTAScene: React.FC = ({ { label: "Web", value: BRAND.website }, ]; - // --- Pulsing subscribe button --- - const pulse = interpolate( - frame % 30, - [0, 15, 30], - [1, 1.05, 1], - { extrapolateRight: "clamp" } - ); - return ( - + {/* Background gradient */} + {/* Animated geometric shapes */} + {GEO_SHAPES.map((shape, i) => ( + + ))} + + {/* Confetti burst */} + {CONFETTI_PARTICLES.map((particle, i) => { + const distance = particle.speed * confettiProgress * 120; + const px = Math.cos(particle.angle) * distance; + const py = Math.sin(particle.angle) * distance - confettiProgress * 40; + const rotation = frame * particle.rotationSpeed; + + return ( +
+ {particle.shape === "square" ? ( +
+ ) : ( +
+ )} +
+ ); + })} + {/* Content */} = ({ alignItems: "center", padding: isVertical ? 40 : 80, flexDirection: "column", - gap: isVertical ? 30 : 40, + gap: isVertical ? 24 : 32, }} > {/* Brand */} @@ -87,6 +303,7 @@ export const CTAScene: React.FC = ({ fontWeight: 700, letterSpacing: 3, textTransform: "uppercase", + opacity: interpolate(ctaSpring, [0, 1], [0, 0.8]), }} > {BRAND.name} @@ -108,88 +325,161 @@ export const CTAScene: React.FC = ({ fontWeight: 700, textAlign: "center", lineHeight: 1.4, - textShadow: "0 2px 20px rgba(0, 0, 0, 0.4)", + textShadow: `0 2px 20px rgba(0, 0, 0, 0.4), 0 0 40px ${COLORS.primary}66`, }} > {cta}
- {/* Subscribe Button */} + {/* "Don't miss out!" urgency text */}
- Subscribe + Don't miss out!
- {/* Social Links */} + {/* Subscribe Button with Arrow */} +
+ {/* Animated arrow pointing to button */} +
+ ▶ +
+ + {/* Subscribe Button */} +
+
+ ▶ Subscribe +
+
+ + {/* Arrow on the other side */} +
+ ▶ +
+
+ + {/* Social Links as Cards */}
{socialLinks.map((link, i) => { const linkSpring = spring({ - frame: frame - 45 - i * 8, + frame: frame - 50 - i * 8, fps, config: { damping: 12, mass: 0.4, stiffness: 100 }, }); + const borderColor = SOCIAL_COLORS[link.label] || COLORS.secondary; + return (
- {link.label} -
-
- {link.value} +
+ {link.label} +
+
+ {link.value} +
); diff --git a/remotion/components/HookScene.tsx b/remotion/components/HookScene.tsx index 692a46c2..1cb62865 100644 --- a/remotion/components/HookScene.tsx +++ b/remotion/components/HookScene.tsx @@ -9,6 +9,232 @@ import { import type { HookSceneProps } from "../types"; import { ANIMATION, BRAND, COLORS, FONT_SIZES } from "../constants"; +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** Split hook text into 2-3 roughly equal lines for staggered reveal */ +function splitIntoLines(text: string, maxLines = 3): string[] { + const words = text.split(/\s+/); + if (words.length <= 3) return [text]; + + const lineCount = Math.min(maxLines, Math.max(2, Math.ceil(words.length / 5))); + const wordsPerLine = Math.ceil(words.length / lineCount); + const lines: string[] = []; + + for (let i = 0; i < words.length; i += wordsPerLine) { + lines.push(words.slice(i, i + wordsPerLine).join(" ")); + } + return lines; +} + +/** Deterministic pseudo-random from seed */ +function seededRandom(seed: number): number { + const x = Math.sin(seed * 9301 + 49297) * 49297; + return x - Math.floor(x); +} + +// ─── Sub-components ───────────────────────────────────────────────────────── + +/** Animated dot grid background */ +const DotGrid: React.FC<{ + frame: number; + durationInFrames: number; + isVertical: boolean; +}> = ({ frame, durationInFrames, isVertical }) => { + const cols = isVertical ? 10 : 16; + const rows = isVertical ? 18 : 10; + const dotSpacingX = isVertical ? 108 : 120; + const dotSpacingY = isVertical ? 107 : 108; + + // Slow drift of the entire grid + const driftX = interpolate(frame, [0, durationInFrames], [0, 30], { + extrapolateRight: "clamp", + }); + const driftY = interpolate(frame, [0, durationInFrames], [0, -20], { + extrapolateRight: "clamp", + }); + + const dots: React.ReactNode[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const seed = r * cols + c; + const phase = seededRandom(seed) * 120; + // Subtle pulse per dot + const pulse = interpolate( + Math.sin(((frame + phase) / 40) * Math.PI * 2), + [-1, 1], + [0.15, 0.4] + ); + dots.push( +
+ ); + } + } + + return ( + {dots} + ); +}; + +/** Floating particles that drift upward */ +const Particles: React.FC<{ + frame: number; + durationInFrames: number; + isVertical: boolean; +}> = ({ frame, durationInFrames, isVertical }) => { + const count = 18; + const particles: React.ReactNode[] = []; + const baseWidth = isVertical ? 1080 : 1920; + const baseHeight = isVertical ? 1920 : 1080; + + for (let i = 0; i < count; i++) { + const startX = seededRandom(i * 7 + 1) * baseWidth; + const startY = baseHeight * 0.6 + seededRandom(i * 7 + 2) * baseHeight * 0.5; + const speed = 0.8 + seededRandom(i * 7 + 3) * 1.5; + const size = 3 + seededRandom(i * 7 + 4) * 5; + const wobbleAmp = 15 + seededRandom(i * 7 + 5) * 30; + const wobbleFreq = 0.03 + seededRandom(i * 7 + 6) * 0.04; + const delay = seededRandom(i * 7 + 7) * 40; + + const effectiveFrame = Math.max(0, frame - delay); + const y = startY - effectiveFrame * speed; + const x = startX + Math.sin(effectiveFrame * wobbleFreq) * wobbleAmp; + + const opacity = interpolate( + effectiveFrame, + [0, 15, durationInFrames * 0.7, durationInFrames], + [0, 0.7, 0.7, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + const isAccent = i % 4 === 0; + particles.push( +
+ ); + } + + return {particles}; +}; + +/** Glowing sweep lines that cross the screen before text appears */ +const GlowSweeps: React.FC<{ + frame: number; + fps: number; + isVertical: boolean; +}> = ({ frame, fps, isVertical }) => { + const screenW = isVertical ? 1080 : 1920; + const screenH = isVertical ? 1920 : 1080; + + // Line 1: horizontal sweep at ~frame 5-25 + const sweep1Progress = interpolate(frame, [5, 25], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const sweep1X = interpolate(sweep1Progress, [0, 1], [-400, screenW + 400]); + const sweep1Opacity = interpolate( + sweep1Progress, + [0, 0.2, 0.8, 1], + [0, 0.9, 0.9, 0] + ); + + // Line 2: diagonal sweep at ~frame 10-30 + const sweep2Progress = interpolate(frame, [10, 30], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const sweep2X = interpolate(sweep2Progress, [0, 1], [-400, screenW + 400]); + const sweep2Opacity = interpolate( + sweep2Progress, + [0, 0.2, 0.8, 1], + [0, 0.7, 0.7, 0] + ); + + // Line 3: vertical accent at ~frame 8-22 + const sweep3Progress = interpolate(frame, [8, 22], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const sweep3Y = interpolate(sweep3Progress, [0, 1], [-200, screenH + 200]); + const sweep3Opacity = interpolate( + sweep3Progress, + [0, 0.3, 0.7, 1], + [0, 0.6, 0.6, 0] + ); + + return ( + + {/* Horizontal glow line */} +
+ {/* Diagonal glow line */} +
+ {/* Vertical accent line */} +
+ + ); +}; + +// ─── Main Component ───────────────────────────────────────────────────────── + export const HookScene: React.FC = ({ hook, durationInFrames, @@ -18,54 +244,95 @@ export const HookScene: React.FC = ({ const { fps } = useVideoConfig(); const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; - // --- Background pulse animation --- - const bgPulse = interpolate( - frame, - [0, durationInFrames], - [0, 360], - { extrapolateRight: "clamp" } - ); + // ── Dynamic gradient background ────────────────────────────────────────── + // Rotate hue and shift gradient angle over the scene + const gradientAngle = interpolate(frame, [0, durationInFrames], [135, 315], { + extrapolateRight: "clamp", + }); + // We'll use CSS hue-rotate for dynamic color shifting + const hueShift = interpolate(frame, [0, durationInFrames], [0, 30], { + extrapolateRight: "clamp", + }); - // --- Logo / brand fade in --- - const brandOpacity = interpolate( - frame, - [0, 15], - [0, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + // ── Brand typing effect ────────────────────────────────────────────────── + const brandText = BRAND.name; + const TYPING_START = 5; + const CHARS_PER_FRAME = 0.6; // ~18 chars / 30 frames = done in ~1s + const charsVisible = Math.min( + brandText.length, + Math.max(0, Math.floor((frame - TYPING_START) * CHARS_PER_FRAME)) ); + const displayedBrand = brandText.slice(0, charsVisible); + const typingDone = charsVisible >= brandText.length; - const brandScale = spring({ - frame, - fps, - config: { - damping: ANIMATION.springDamping, - mass: ANIMATION.springMass, - stiffness: ANIMATION.springStiffness, - }, + // Cursor blink: toggles every 8 frames + const cursorVisible = !typingDone || Math.floor(frame / 8) % 2 === 0; + const cursorOpacity = typingDone + ? cursorVisible + ? 0.8 + : 0 + : 1; + + // Brand container fade in + const brandContainerOpacity = interpolate(frame, [TYPING_START, TYPING_START + 5], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", }); - // --- Hook text animation: spring pop-in after brand --- - const hookSpring = spring({ - frame: frame - 20, // delay 20 frames after start + // Brand accent line under brand + const brandLineWidth = spring({ + frame: frame - TYPING_START - 15, fps, - config: { damping: 14, mass: 0.6, stiffness: 80 }, + config: { damping: 15, mass: 0.4, stiffness: 120 }, }); - const hookOpacity = interpolate( - hookSpring, - [0, 1], - [0, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ); + // ── Hook text staggered reveal ─────────────────────────────────────────── + const hookLines = splitIntoLines(hook, 3); + const HOOK_START = 35; // frames — after brand typing finishes + const LINE_STAGGER = 9; // frames between each line - const hookScale = interpolate( - hookSpring, - [0, 1], - [0.7, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ); + // Slide directions for each line: alternate left/right/left + const slideDirections = [1, -1, 1]; // 1 = from right, -1 = from left - // --- Fade out at end --- + const lineAnimations = hookLines.map((_, i) => { + const lineDelay = HOOK_START + i * LINE_STAGGER; + const lineSpring = spring({ + frame: frame - lineDelay, + fps, + config: { damping: 14, mass: 0.5, stiffness: 90 }, + }); + const opacity = interpolate(lineSpring, [0, 1], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const translateX = interpolate(lineSpring, [0, 1], [80 * slideDirections[i % 3], 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const translateY = interpolate(lineSpring, [0, 1], [20, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + return { opacity, translateX, translateY }; + }); + + // ── Animated underline under last hook line ────────────────────────────── + const underlineDelay = HOOK_START + hookLines.length * LINE_STAGGER + 8; + const underlineProgress = spring({ + frame: frame - underlineDelay, + fps, + config: { damping: 18, mass: 0.4, stiffness: 100 }, + }); + const underlineWidth = interpolate(underlineProgress, [0, 1], [0, 100], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const underlineOpacity = interpolate(underlineProgress, [0, 0.3], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + // ── Fade out at end ────────────────────────────────────────────────────── const fadeOut = interpolate( frame, [durationInFrames - ANIMATION.fadeOut, durationInFrames], @@ -73,105 +340,185 @@ export const HookScene: React.FC = ({ { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); + // ── Decorative circles (enhanced) ──────────────────────────────────────── + const circle1Scale = interpolate(frame, [0, 60], [0.3, 1.3], { + extrapolateRight: "clamp", + }); + const circle1Pulse = Math.sin(frame * 0.05) * 0.1; + + const circle2Scale = interpolate(frame, [10, 70], [0.2, 1.1], { + extrapolateRight: "clamp", + }); + const circle2Pulse = Math.sin(frame * 0.04 + 1) * 0.08; + return ( - {/* Animated gradient background */} + {/* ── Dynamic gradient background ── */} + + + {/* ── Secondary gradient overlay for depth ── */} - {/* Decorative circles */} + {/* ── Dot grid background ── */} + + + {/* ── Floating particles ── */} + + + {/* ── Glow sweep lines ── */} + + + {/* ── Decorative circles ── */}
- {/* Content container */} + {/* ── Content container ── */} - {/* Brand name */} + {/* ── Brand name with typing effect ── */}
- {BRAND.name} + {displayedBrand} + {/* Blinking cursor */} +
+ {/* Animated accent line under brand */}
- {/* Hook text */} + {/* ── Hook text with staggered line reveal ── */}
+ {hookLines.map((line, i) => { + const anim = lineAnimations[i]; + return ( +
+ {line} +
+ ); + })} + + {/* ── Animated underline ── */}
- {hook} -
+ />
diff --git a/remotion/components/Scene.tsx b/remotion/components/Scene.tsx index be9d2e6b..221ed268 100644 --- a/remotion/components/Scene.tsx +++ b/remotion/components/Scene.tsx @@ -3,25 +3,35 @@ import { AbsoluteFill, OffthreadVideo, interpolate, + spring, useCurrentFrame, useVideoConfig, } from "remotion"; import type { SceneProps } from "../types"; -import { ANIMATION, COLORS, FONT_SIZES } from "../constants"; +import { ANIMATION, COLORS, FONT_SIZES, FPS } from "../constants"; export const Scene: React.FC = ({ narration, bRollUrl, visualDescription, + sceneNumber, sceneIndex, durationInFrames, isVertical = false, }) => { const frame = useCurrentFrame(); - const { width, height } = useVideoConfig(); + const { width, height, fps } = useVideoConfig(); const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; - // --- Text animation: fade in → stay → fade out --- + // --- Scene-level fade in/out --- + const sceneOpacity = interpolate( + frame, + [0, 15, durationInFrames - ANIMATION.fadeOut, durationInFrames], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + // --- Text container animation: fade in + slide up --- const textOpacity = interpolate( frame, [0, ANIMATION.fadeIn, durationInFrames - ANIMATION.fadeOut, durationInFrames], @@ -29,7 +39,6 @@ export const Scene: React.FC = ({ { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); - // Subtle slide-up for text const textTranslateY = interpolate( frame, [0, ANIMATION.fadeIn], @@ -37,35 +46,105 @@ export const Scene: React.FC = ({ { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); - // Scene transition: fade in at start - const sceneOpacity = interpolate( + // --- Ken Burns zoom for B-roll (slow zoom from 1.0 to 1.08 over scene) --- + const kenBurnsScale = interpolate( frame, - [0, 15], - [0, 1], + [0, durationInFrames], + [1.0, 1.08], { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); + // --- Scene progress bar --- + const progressWidth = interpolate( + frame, + [0, durationInFrames], + [0, 100], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + // --- Scene number indicator fade-in --- + const sceneNumberOpacity = interpolate( + frame, + [0, ANIMATION.fadeIn * 0.5, ANIMATION.fadeIn], + [0, 0, 0.5], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + // --- Accent line animation (slides in from left before text) --- + const accentLineWidth = spring({ + frame, + fps, + config: { + damping: ANIMATION.springDamping, + mass: ANIMATION.springMass, + stiffness: ANIMATION.springStiffness, + }, + }); + + const accentLineOpacity = interpolate( + frame, + [0, 8, durationInFrames - ANIMATION.fadeOut, durationInFrames], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + // --- Glow pulse for text container border --- + const glowIntensity = interpolate( + frame, + [0, ANIMATION.fadeIn, ANIMATION.fadeIn + 20, ANIMATION.fadeIn + 40], + [0, 0, 12, 8], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + + // --- Word-by-word reveal --- + const words = narration.split(/\s+/).filter(Boolean); + const totalWords = words.length; + // Spread word reveals across the available time (leave some buffer at end) + const revealWindow = Math.max(durationInFrames * 0.65, ANIMATION.fadeIn + totalWords * 2); + const framesPerWord = Math.max(2, (revealWindow - ANIMATION.fadeIn * 0.5) / totalWords); + // Alternating gradient directions for visual variety const gradientAngle = (sceneIndex % 4) * 90; + // Format scene number as 01, 02, etc. + const displayNumber = sceneNumber !== undefined + ? String(sceneNumber).padStart(2, "0") + : String(sceneIndex + 1).padStart(2, "0"); + return ( - {/* Layer 1: Background — B-roll or gradient fallback */} + {/* Layer 1: Background — B-roll with Ken Burns or gradient fallback */} {bRollUrl ? ( - - {/* Dark overlay for text readability */} + > + + + {/* Gradient overlay — darker at bottom for text readability */} @@ -94,7 +173,25 @@ export const Scene: React.FC = ({
)} - {/* Layer 3: Narration text overlay */} + {/* Layer 3: Scene number indicator (top-right) */} +
+ {displayNumber} +
+ + {/* Layer 4: Accent line + Narration text overlay */} = ({ padding: isVertical ? "60px 40px" : "80px 120px", }} > + {/* Accent line — slides in before text */} +
+ + {/* Improved text container — modern card with glow */}
+ {/* Word-by-word text reveal */}
- {narration} + {words.map((word, i) => { + const wordStartFrame = ANIMATION.fadeIn * 0.3 + i * framesPerWord; + const wordOpacity = interpolate( + frame, + [ + wordStartFrame, + wordStartFrame + 6, + durationInFrames - ANIMATION.fadeOut, + durationInFrames, + ], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + const wordScale = interpolate( + frame, + [wordStartFrame, wordStartFrame + 6], + [0.88, 1], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + return ( + + {word} + + ); + })}
- {/* Layer 4: CodingCat.dev watermark */} + {/* Layer 5: Scene progress bar */} +
+
+
+ + {/* Layer 6: CodingCat.dev watermark */}
= ({ const totalFrames = Math.ceil(audioDurationInSeconds * FPS); - // Calculate how many frames are available for content scenes - const scenesAvailableFrames = totalFrames - hookDuration - ctaDuration; + // Calculate available frames for content scenes. + // With cross-fade overlap, each transition saves TRANSITION_DURATION frames. + // Transitions: hook→scene1, scene1→scene2, ..., sceneN→CTA + // Total transitions = scenes.length + 1 (hook-to-first-scene + between-scenes + last-scene-to-CTA) + const numTransitions = scenes.length + 1; + const overlapSavings = numTransitions * TRANSITION_DURATION; + const scenesAvailableFrames = totalFrames - hookDuration - ctaDuration + overlapSavings; // Distribute scene frames evenly across all scenes const perSceneFrames = Math.max( @@ -44,8 +50,24 @@ export const MainVideo: React.FC = ({ // Sponsor insertion point in frames (~15s mark) const sponsorInsertFrame = SPONSOR_INSERT_SECONDS * FPS; - // Build the sequence timeline - let currentFrame = 0; + // --- Build timeline with cross-fade overlaps --- + // Each scene starts TRANSITION_DURATION frames before the previous one ends. + // Hook starts at 0. + const hookStart = 0; + + // First content scene overlaps with the end of the hook + const firstSceneStart = hookStart + hookDuration - TRANSITION_DURATION; + + // Calculate scene start positions + const sceneStarts = scenes.map((_, index) => + firstSceneStart + index * (perSceneFrames - TRANSITION_DURATION) + ); + + // CTA overlaps with the end of the last content scene + const lastSceneEnd = sceneStarts.length > 0 + ? sceneStarts[sceneStarts.length - 1] + perSceneFrames + : hookStart + hookDuration; + const ctaStart = lastSceneEnd - TRANSITION_DURATION; return ( @@ -53,35 +75,36 @@ export const MainVideo: React.FC = ({ ); diff --git a/remotion/compositions/ShortVideo.tsx b/remotion/compositions/ShortVideo.tsx index 9b4fad7d..0e2f0923 100644 --- a/remotion/compositions/ShortVideo.tsx +++ b/remotion/compositions/ShortVideo.tsx @@ -7,6 +7,7 @@ import { DEFAULT_SPONSOR_DURATION, FPS, SPONSOR_INSERT_SECONDS, + TRANSITION_DURATION, } from "../constants"; import { HookScene } from "../components/HookScene"; import { Scene } from "../components/Scene"; @@ -32,8 +33,11 @@ export const ShortVideo: React.FC = ({ const totalFrames = Math.ceil(audioDurationInSeconds * FPS); - // Calculate how many frames are available for content scenes - const scenesAvailableFrames = totalFrames - hookDuration - ctaDuration; + // Calculate available frames for content scenes. + // With cross-fade overlap, each transition saves TRANSITION_DURATION frames. + const numTransitions = scenes.length + 1; + const overlapSavings = numTransitions * TRANSITION_DURATION; + const scenesAvailableFrames = totalFrames - hookDuration - ctaDuration + overlapSavings; // Distribute scene frames evenly across all scenes const perSceneFrames = Math.max( @@ -44,37 +48,59 @@ export const ShortVideo: React.FC = ({ // Sponsor insertion point in frames (~15s mark) const sponsorInsertFrame = SPONSOR_INSERT_SECONDS * FPS; + // --- Build timeline with cross-fade overlaps --- + const hookStart = 0; + + // First content scene overlaps with the end of the hook + const firstSceneStart = hookStart + hookDuration - TRANSITION_DURATION; + + // Calculate scene start positions + const sceneStarts = scenes.map((_, index) => + firstSceneStart + index * (perSceneFrames - TRANSITION_DURATION) + ); + + // CTA overlaps with the end of the last content scene + const lastSceneEnd = sceneStarts.length > 0 + ? sceneStarts[sceneStarts.length - 1] + perSceneFrames + : hookStart + hookDuration; + const ctaStart = lastSceneEnd - TRANSITION_DURATION; + return ( {/* Audio track — plays for the entire composition */} ); diff --git a/remotion/index.ts b/remotion/index.ts index 7ca76d9d..2ea23fa0 100644 --- a/remotion/index.ts +++ b/remotion/index.ts @@ -9,6 +9,11 @@ * - Programmatic render: import RemotionRoot and use with @remotion/renderer */ +import { registerRoot } from "remotion"; +import { RemotionRoot } from "./Root"; + +registerRoot(RemotionRoot); + export { RemotionRoot } from "./Root"; export type { VideoInputProps, From f0eefb6eeac9be9942da15cf4d60aba0ca2d95a5 Mon Sep 17 00:00:00 2001 From: Miriad Date: Tue, 3 Mar 2026 14:23:10 +0000 Subject: [PATCH 2/2] fix(remotion): address PR #592 review feedback - Use useVideoConfig() instead of hardcoded dimensions in HookScene - Reduce DotGrid to ~48 dots for Lambda performance - Add word chunking for narrations >60 words - Add missing extrapolateLeft: "clamp" for consistency - Remove unused imports (FPS in Scene, fps in GlowSweeps) - Extract shared VideoComposition component - Replace execSync("rm -rf") with fs.rmSync() --- lib/services/ffmpeg-compress.ts | 4 +- remotion/components/HookScene.tsx | 38 ++++-- remotion/components/Scene.tsx | 46 ++++--- remotion/compositions/MainVideo.tsx | 137 +------------------- remotion/compositions/ShortVideo.tsx | 133 +------------------ remotion/compositions/VideoComposition.tsx | 143 +++++++++++++++++++++ 6 files changed, 206 insertions(+), 295 deletions(-) create mode 100644 remotion/compositions/VideoComposition.tsx diff --git a/lib/services/ffmpeg-compress.ts b/lib/services/ffmpeg-compress.ts index c79b5b81..ac8d4e05 100644 --- a/lib/services/ffmpeg-compress.ts +++ b/lib/services/ffmpeg-compress.ts @@ -8,7 +8,7 @@ */ import { execFileSync, execSync } from "child_process"; -import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from "fs"; +import { writeFileSync, readFileSync, unlinkSync, mkdtempSync, rmSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -133,7 +133,7 @@ function makeTempDir() { } try { // Remove the temp directory itself - execSync(`rm -rf "${dir}"`, { stdio: "ignore" }); + rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } diff --git a/remotion/components/HookScene.tsx b/remotion/components/HookScene.tsx index 1cb62865..dcd9f464 100644 --- a/remotion/components/HookScene.tsx +++ b/remotion/components/HookScene.tsx @@ -40,16 +40,18 @@ const DotGrid: React.FC<{ durationInFrames: number; isVertical: boolean; }> = ({ frame, durationInFrames, isVertical }) => { - const cols = isVertical ? 10 : 16; - const rows = isVertical ? 18 : 10; - const dotSpacingX = isVertical ? 108 : 120; - const dotSpacingY = isVertical ? 107 : 108; + const cols = isVertical ? 6 : 8; + const rows = isVertical ? 8 : 6; + const dotSpacingX = isVertical ? 180 : 240; + const dotSpacingY = isVertical ? 240 : 180; // Slow drift of the entire grid const driftX = interpolate(frame, [0, durationInFrames], [0, 30], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const driftY = interpolate(frame, [0, durationInFrames], [0, -20], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", }); @@ -92,11 +94,13 @@ const Particles: React.FC<{ frame: number; durationInFrames: number; isVertical: boolean; -}> = ({ frame, durationInFrames, isVertical }) => { + width: number; + height: number; +}> = ({ frame, durationInFrames, isVertical, width, height }) => { const count = 18; const particles: React.ReactNode[] = []; - const baseWidth = isVertical ? 1080 : 1920; - const baseHeight = isVertical ? 1920 : 1080; + const baseWidth = width; + const baseHeight = height; for (let i = 0; i < count; i++) { const startX = seededRandom(i * 7 + 1) * baseWidth; @@ -145,11 +149,12 @@ const Particles: React.FC<{ /** Glowing sweep lines that cross the screen before text appears */ const GlowSweeps: React.FC<{ frame: number; - fps: number; isVertical: boolean; -}> = ({ frame, fps, isVertical }) => { - const screenW = isVertical ? 1080 : 1920; - const screenH = isVertical ? 1920 : 1080; + width: number; + height: number; +}> = ({ frame, isVertical, width, height }) => { + const screenW = width; + const screenH = height; // Line 1: horizontal sweep at ~frame 5-25 const sweep1Progress = interpolate(frame, [5, 25], [0, 1], { @@ -241,16 +246,18 @@ export const HookScene: React.FC = ({ isVertical = false, }) => { const frame = useCurrentFrame(); - const { fps } = useVideoConfig(); + const { fps, width, height } = useVideoConfig(); const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; // ── Dynamic gradient background ────────────────────────────────────────── // Rotate hue and shift gradient angle over the scene const gradientAngle = interpolate(frame, [0, durationInFrames], [135, 315], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", }); // We'll use CSS hue-rotate for dynamic color shifting const hueShift = interpolate(frame, [0, durationInFrames], [0, 30], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", }); @@ -342,11 +349,13 @@ export const HookScene: React.FC = ({ // ── Decorative circles (enhanced) ──────────────────────────────────────── const circle1Scale = interpolate(frame, [0, 60], [0.3, 1.3], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const circle1Pulse = Math.sin(frame * 0.05) * 0.1; const circle2Scale = interpolate(frame, [10, 70], [0.2, 1.1], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const circle2Pulse = Math.sin(frame * 0.04 + 1) * 0.08; @@ -366,6 +375,7 @@ export const HookScene: React.FC = ({ style={{ background: `radial-gradient(ellipse at ${isVertical ? "50% 40%" : "30% 50%"}, ${COLORS.primary}33 0%, transparent 70%)`, opacity: interpolate(frame, [0, 40], [0, 0.8], { + extrapolateLeft: "clamp", extrapolateRight: "clamp", }), }} @@ -383,10 +393,12 @@ export const HookScene: React.FC = ({ frame={frame} durationInFrames={durationInFrames} isVertical={isVertical} + width={width} + height={height} /> {/* ── Glow sweep lines ── */} - + {/* ── Decorative circles ── */}
= ({ narration, @@ -96,12 +100,22 @@ export const Scene: React.FC = ({ { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); - // --- Word-by-word reveal --- + // --- Word-by-word reveal (with chunking for long narrations) --- const words = narration.split(/\s+/).filter(Boolean); const totalWords = words.length; - // Spread word reveals across the available time (leave some buffer at end) - const revealWindow = Math.max(durationInFrames * 0.65, ANIMATION.fadeIn + totalWords * 2); - const framesPerWord = Math.max(2, (revealWindow - ANIMATION.fadeIn * 0.5) / totalWords); + const useChunking = totalWords > WORD_CHUNK_THRESHOLD; + + // Build display units: either individual words or chunks of CHUNK_SIZE + const displayUnits: string[] = useChunking + ? Array.from({ length: Math.ceil(totalWords / CHUNK_SIZE) }, (_, i) => + words.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE).join(" ") + ) + : words; + const totalUnits = displayUnits.length; + + // Spread reveals across the available time (leave some buffer at end) + const revealWindow = Math.max(durationInFrames * 0.65, ANIMATION.fadeIn + totalUnits * 2); + const framesPerUnit = Math.max(2, (revealWindow - ANIMATION.fadeIn * 0.5) / totalUnits); // Alternating gradient directions for visual variety const gradientAngle = (sceneIndex % 4) * 90; @@ -247,36 +261,36 @@ export const Scene: React.FC = ({ gap: "0 0.35em", }} > - {words.map((word, i) => { - const wordStartFrame = ANIMATION.fadeIn * 0.3 + i * framesPerWord; - const wordOpacity = interpolate( + {displayUnits.map((unit, i) => { + const unitStartFrame = ANIMATION.fadeIn * 0.3 + i * framesPerUnit; + const unitOpacity = interpolate( frame, [ - wordStartFrame, - wordStartFrame + 6, + unitStartFrame, + unitStartFrame + 6, durationInFrames - ANIMATION.fadeOut, durationInFrames, ], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); - const wordScale = interpolate( + const unitScale = interpolate( frame, - [wordStartFrame, wordStartFrame + 6], + [unitStartFrame, unitStartFrame + 6], [0.88, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); return ( - {word} + {unit} ); })} diff --git a/remotion/compositions/MainVideo.tsx b/remotion/compositions/MainVideo.tsx index e1e8a549..502671a4 100644 --- a/remotion/compositions/MainVideo.tsx +++ b/remotion/compositions/MainVideo.tsx @@ -1,138 +1,7 @@ import React from "react"; -import { AbsoluteFill, Audio, Sequence } from "remotion"; import type { VideoInputProps } from "../types"; -import { - DEFAULT_CTA_DURATION, - DEFAULT_HOOK_DURATION, - DEFAULT_SPONSOR_DURATION, - FPS, - SPONSOR_INSERT_SECONDS, - TRANSITION_DURATION, -} from "../constants"; -import { HookScene } from "../components/HookScene"; -import { Scene } from "../components/Scene"; -import { CTAScene } from "../components/CTAScene"; -import { SponsorSlot } from "../components/SponsorSlot"; +import { VideoComposition } from "./VideoComposition"; -export const MainVideo: React.FC = ({ - audioUrl, - audioDurationInSeconds, - hook, - scenes, - cta, - sponsor, - hookDurationInFrames, - ctaDurationInFrames, - sponsorDurationInFrames, -}) => { - const hookDuration = hookDurationInFrames ?? DEFAULT_HOOK_DURATION; - const ctaDuration = ctaDurationInFrames ?? DEFAULT_CTA_DURATION; - const sponsorDuration = sponsor - ? (sponsorDurationInFrames ?? DEFAULT_SPONSOR_DURATION) - : 0; - - const totalFrames = Math.ceil(audioDurationInSeconds * FPS); - - // Calculate available frames for content scenes. - // With cross-fade overlap, each transition saves TRANSITION_DURATION frames. - // Transitions: hook→scene1, scene1→scene2, ..., sceneN→CTA - // Total transitions = scenes.length + 1 (hook-to-first-scene + between-scenes + last-scene-to-CTA) - const numTransitions = scenes.length + 1; - const overlapSavings = numTransitions * TRANSITION_DURATION; - const scenesAvailableFrames = totalFrames - hookDuration - ctaDuration + overlapSavings; - - // Distribute scene frames evenly across all scenes - const perSceneFrames = Math.max( - FPS, // minimum 1 second per scene - Math.floor(scenesAvailableFrames / scenes.length) - ); - - // Sponsor insertion point in frames (~15s mark) - const sponsorInsertFrame = SPONSOR_INSERT_SECONDS * FPS; - - // --- Build timeline with cross-fade overlaps --- - // Each scene starts TRANSITION_DURATION frames before the previous one ends. - // Hook starts at 0. - const hookStart = 0; - - // First content scene overlaps with the end of the hook - const firstSceneStart = hookStart + hookDuration - TRANSITION_DURATION; - - // Calculate scene start positions - const sceneStarts = scenes.map((_, index) => - firstSceneStart + index * (perSceneFrames - TRANSITION_DURATION) - ); - - // CTA overlaps with the end of the last content scene - const lastSceneEnd = sceneStarts.length > 0 - ? sceneStarts[sceneStarts.length - 1] + perSceneFrames - : hookStart + hookDuration; - const ctaStart = lastSceneEnd - TRANSITION_DURATION; - - return ( - - {/* Audio track — plays for the entire composition */} - - ); +export const MainVideo: React.FC = (props) => { + return ; }; diff --git a/remotion/compositions/ShortVideo.tsx b/remotion/compositions/ShortVideo.tsx index 0e2f0923..bb339546 100644 --- a/remotion/compositions/ShortVideo.tsx +++ b/remotion/compositions/ShortVideo.tsx @@ -1,134 +1,7 @@ import React from "react"; -import { AbsoluteFill, Audio, Sequence } from "remotion"; import type { VideoInputProps } from "../types"; -import { - DEFAULT_CTA_DURATION, - DEFAULT_HOOK_DURATION, - DEFAULT_SPONSOR_DURATION, - FPS, - SPONSOR_INSERT_SECONDS, - TRANSITION_DURATION, -} from "../constants"; -import { HookScene } from "../components/HookScene"; -import { Scene } from "../components/Scene"; -import { CTAScene } from "../components/CTAScene"; -import { SponsorSlot } from "../components/SponsorSlot"; +import { VideoComposition } from "./VideoComposition"; -export const ShortVideo: React.FC = ({ - audioUrl, - audioDurationInSeconds, - hook, - scenes, - cta, - sponsor, - hookDurationInFrames, - ctaDurationInFrames, - sponsorDurationInFrames, -}) => { - const hookDuration = hookDurationInFrames ?? DEFAULT_HOOK_DURATION; - const ctaDuration = ctaDurationInFrames ?? DEFAULT_CTA_DURATION; - const sponsorDuration = sponsor - ? (sponsorDurationInFrames ?? DEFAULT_SPONSOR_DURATION) - : 0; - - const totalFrames = Math.ceil(audioDurationInSeconds * FPS); - - // Calculate available frames for content scenes. - // With cross-fade overlap, each transition saves TRANSITION_DURATION frames. - const numTransitions = scenes.length + 1; - const overlapSavings = numTransitions * TRANSITION_DURATION; - const scenesAvailableFrames = totalFrames - hookDuration - ctaDuration + overlapSavings; - - // Distribute scene frames evenly across all scenes - const perSceneFrames = Math.max( - FPS, // minimum 1 second per scene - Math.floor(scenesAvailableFrames / scenes.length) - ); - - // Sponsor insertion point in frames (~15s mark) - const sponsorInsertFrame = SPONSOR_INSERT_SECONDS * FPS; - - // --- Build timeline with cross-fade overlaps --- - const hookStart = 0; - - // First content scene overlaps with the end of the hook - const firstSceneStart = hookStart + hookDuration - TRANSITION_DURATION; - - // Calculate scene start positions - const sceneStarts = scenes.map((_, index) => - firstSceneStart + index * (perSceneFrames - TRANSITION_DURATION) - ); - - // CTA overlaps with the end of the last content scene - const lastSceneEnd = sceneStarts.length > 0 - ? sceneStarts[sceneStarts.length - 1] + perSceneFrames - : hookStart + hookDuration; - const ctaStart = lastSceneEnd - TRANSITION_DURATION; - - return ( - - {/* Audio track — plays for the entire composition */} - - ); +export const ShortVideo: React.FC = (props) => { + return ; }; diff --git a/remotion/compositions/VideoComposition.tsx b/remotion/compositions/VideoComposition.tsx new file mode 100644 index 00000000..510af569 --- /dev/null +++ b/remotion/compositions/VideoComposition.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { AbsoluteFill, Audio, Sequence } from "remotion"; +import type { VideoInputProps } from "../types"; +import { + DEFAULT_CTA_DURATION, + DEFAULT_HOOK_DURATION, + DEFAULT_SPONSOR_DURATION, + FPS, + SPONSOR_INSERT_SECONDS, + TRANSITION_DURATION, +} from "../constants"; +import { HookScene } from "../components/HookScene"; +import { Scene } from "../components/Scene"; +import { CTAScene } from "../components/CTAScene"; +import { SponsorSlot } from "../components/SponsorSlot"; + +export interface VideoCompositionProps extends VideoInputProps { + isVertical: boolean; +} + +export const VideoComposition: React.FC = ({ + audioUrl, + audioDurationInSeconds, + hook, + scenes, + cta, + sponsor, + hookDurationInFrames, + ctaDurationInFrames, + sponsorDurationInFrames, + isVertical, +}) => { + const hookDuration = hookDurationInFrames ?? DEFAULT_HOOK_DURATION; + const ctaDuration = ctaDurationInFrames ?? DEFAULT_CTA_DURATION; + const sponsorDuration = sponsor + ? (sponsorDurationInFrames ?? DEFAULT_SPONSOR_DURATION) + : 0; + + const totalFrames = Math.ceil(audioDurationInSeconds * FPS); + + // Calculate available frames for content scenes. + // With cross-fade overlap, each transition saves TRANSITION_DURATION frames. + // Transitions: hook→scene1, scene1→scene2, ..., sceneN→CTA + // Total transitions = scenes.length + 1 (hook-to-first-scene + between-scenes + last-scene-to-CTA) + const numTransitions = scenes.length + 1; + const overlapSavings = numTransitions * TRANSITION_DURATION; + const scenesAvailableFrames = totalFrames - hookDuration - ctaDuration + overlapSavings; + + // Distribute scene frames evenly across all scenes + const perSceneFrames = Math.max( + FPS, // minimum 1 second per scene + Math.floor(scenesAvailableFrames / scenes.length) + ); + + // Sponsor insertion point in frames (~15s mark) + const sponsorInsertFrame = SPONSOR_INSERT_SECONDS * FPS; + + // --- Build timeline with cross-fade overlaps --- + // Each scene starts TRANSITION_DURATION frames before the previous one ends. + // Hook starts at 0. + const hookStart = 0; + + // First content scene overlaps with the end of the hook + const firstSceneStart = hookStart + hookDuration - TRANSITION_DURATION; + + // Calculate scene start positions + const sceneStarts = scenes.map((_, index) => + firstSceneStart + index * (perSceneFrames - TRANSITION_DURATION) + ); + + // CTA overlaps with the end of the last content scene + const lastSceneEnd = sceneStarts.length > 0 + ? sceneStarts[sceneStarts.length - 1] + perSceneFrames + : hookStart + hookDuration; + const ctaStart = lastSceneEnd - TRANSITION_DURATION; + + return ( + + {/* Audio track — plays for the entire composition */} + + ); +};