Skip to content

Commit 84f0b56

Browse files
author
Miriad
committed
feat(remotion): polish compositions for Cleo Abram-style visuals
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
1 parent 9c6287f commit 84f0b56

File tree

7 files changed

+1483
-215
lines changed

7 files changed

+1483
-215
lines changed

lib/services/ffmpeg-compress.ts

Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
1+
/**
2+
* FFmpeg post-render compression for video pipeline.
3+
*
4+
* Downloads the rendered video from Remotion Lambda's S3 output,
5+
* compresses it with FFmpeg, and returns the compressed buffer.
6+
*
7+
* Uses two-pass encoding for optimal quality/size ratio.
8+
*/
9+
10+
import { execFileSync, execSync } from "child_process";
11+
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from "fs";
12+
import { join } from "path";
13+
import { tmpdir } from "os";
14+
15+
// ---------------------------------------------------------------------------
16+
// Types
17+
// ---------------------------------------------------------------------------
18+
19+
export interface CompressOptions {
20+
/** Target bitrate for video (default: "2M" for 2 Mbps) */
21+
videoBitrate?: string;
22+
/** Audio bitrate (default: "128k") */
23+
audioBitrate?: string;
24+
/** CRF value for quality (default: 23, lower = better quality) */
25+
crf?: number;
26+
/** Preset (default: "medium", options: ultrafast … veryslow) */
27+
preset?: string;
28+
/** Max width — will scale down if larger, maintaining aspect ratio */
29+
maxWidth?: number;
30+
/** Max height — will scale down if larger, maintaining aspect ratio */
31+
maxHeight?: number;
32+
}
33+
34+
export interface CompressResult {
35+
/** Compressed video as Buffer */
36+
buffer: Buffer;
37+
/** Original size in bytes */
38+
originalSize: number;
39+
/** Compressed size in bytes */
40+
compressedSize: number;
41+
/** Compression ratio (e.g., 0.6 means 60% of original) */
42+
ratio: number;
43+
/** Duration of compression in ms */
44+
durationMs: number;
45+
}
46+
47+
export interface VideoMetadata {
48+
/** Duration in seconds */
49+
duration: number;
50+
/** Width in pixels */
51+
width: number;
52+
/** Height in pixels */
53+
height: number;
54+
/** Video codec (e.g. "h264") */
55+
videoCodec: string;
56+
/** Audio codec (e.g. "aac") */
57+
audioCodec: string;
58+
/** Overall bitrate in bits/s */
59+
bitrate: number;
60+
/** File size in bytes */
61+
fileSize: number;
62+
}
63+
64+
// ---------------------------------------------------------------------------
65+
// Helpers
66+
// ---------------------------------------------------------------------------
67+
68+
const LOG_PREFIX = "[FFMPEG]";
69+
70+
function log(...args: unknown[]) {
71+
console.log(LOG_PREFIX, ...args);
72+
}
73+
74+
function warn(...args: unknown[]) {
75+
console.warn(LOG_PREFIX, ...args);
76+
}
77+
78+
function isFfmpegAvailable(): boolean {
79+
try {
80+
execSync("ffmpeg -version", { stdio: "ignore" });
81+
return true;
82+
} catch {
83+
return false;
84+
}
85+
}
86+
87+
function isFfprobeAvailable(): boolean {
88+
try {
89+
execSync("ffprobe -version", { stdio: "ignore" });
90+
return true;
91+
} catch {
92+
return false;
93+
}
94+
}
95+
96+
/**
97+
* Download a URL to a Buffer. Works in Node 18+ (global fetch).
98+
*/
99+
async function downloadUrl(url: string): Promise<Buffer> {
100+
log("Downloading video from URL …");
101+
const res = await fetch(url);
102+
if (!res.ok) {
103+
throw new Error(`${LOG_PREFIX} Failed to download video: ${res.status} ${res.statusText}`);
104+
}
105+
const arrayBuffer = await res.arrayBuffer();
106+
return Buffer.from(arrayBuffer);
107+
}
108+
109+
/**
110+
* Create a temp directory and return helpers for managing temp files.
111+
*/
112+
function makeTempDir() {
113+
const dir = mkdtempSync(join(tmpdir(), "ffmpeg-compress-"));
114+
const inputPath = join(dir, "input.mp4");
115+
const outputPath = join(dir, "output.mp4");
116+
const passLogPrefix = join(dir, "ffmpeg2pass");
117+
118+
function cleanup() {
119+
for (const f of [inputPath, outputPath]) {
120+
try {
121+
unlinkSync(f);
122+
} catch {
123+
/* ignore */
124+
}
125+
}
126+
// Two-pass log files
127+
for (const suffix of ["", "-0.log", "-0.log.mbtree"]) {
128+
try {
129+
unlinkSync(passLogPrefix + suffix);
130+
} catch {
131+
/* ignore */
132+
}
133+
}
134+
try {
135+
// Remove the temp directory itself
136+
execSync(`rm -rf "${dir}"`, { stdio: "ignore" });
137+
} catch {
138+
/* ignore */
139+
}
140+
}
141+
142+
return { dir, inputPath, outputPath, passLogPrefix, cleanup };
143+
}
144+
145+
// ---------------------------------------------------------------------------
146+
// Build FFmpeg arguments
147+
// ---------------------------------------------------------------------------
148+
149+
function buildScaleFilter(opts: CompressOptions): string | null {
150+
if (!opts.maxWidth && !opts.maxHeight) return null;
151+
152+
const w = opts.maxWidth ? `'min(${opts.maxWidth},iw)'` : "-2";
153+
const h = opts.maxHeight ? `'min(${opts.maxHeight},ih)'` : "-2";
154+
155+
// Ensure dimensions are divisible by 2 for H.264
156+
return `scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2`;
157+
}
158+
159+
function buildFfmpegArgs(
160+
inputPath: string,
161+
outputPath: string,
162+
opts: CompressOptions,
163+
pass: 1 | 2 | "crf",
164+
passLogPrefix?: string,
165+
): string[] {
166+
const args: string[] = ["-y", "-i", inputPath];
167+
168+
// Video codec
169+
args.push("-c:v", "libx264");
170+
171+
// Preset
172+
args.push("-preset", opts.preset || "medium");
173+
174+
// Scale filter
175+
const scaleFilter = buildScaleFilter(opts);
176+
if (scaleFilter) {
177+
args.push("-vf", scaleFilter);
178+
}
179+
180+
if (pass === "crf") {
181+
// Single-pass CRF mode
182+
args.push("-crf", String(opts.crf ?? 23));
183+
// Audio
184+
args.push("-c:a", "aac", "-b:a", opts.audioBitrate || "128k");
185+
// Faststart for web playback
186+
args.push("-movflags", "+faststart");
187+
args.push(outputPath);
188+
} else if (pass === 1) {
189+
// Two-pass: first pass
190+
args.push("-b:v", opts.videoBitrate || "2M");
191+
args.push("-pass", "1");
192+
args.push("-passlogfile", passLogPrefix!);
193+
args.push("-an"); // No audio in first pass
194+
args.push("-f", "null");
195+
args.push(process.platform === "win32" ? "NUL" : "/dev/null");
196+
} else {
197+
// Two-pass: second pass
198+
args.push("-b:v", opts.videoBitrate || "2M");
199+
args.push("-pass", "2");
200+
args.push("-passlogfile", passLogPrefix!);
201+
args.push("-c:a", "aac", "-b:a", opts.audioBitrate || "128k");
202+
args.push("-movflags", "+faststart");
203+
args.push(outputPath);
204+
}
205+
206+
return args;
207+
}
208+
209+
// ---------------------------------------------------------------------------
210+
// Main: compressVideo
211+
// ---------------------------------------------------------------------------
212+
213+
/**
214+
* Compress a video using FFmpeg.
215+
*
216+
* @param input - A video URL (string) or a Buffer containing the video data.
217+
* @param options - Compression options.
218+
* @returns CompressResult with the compressed buffer and stats.
219+
*/
220+
export async function compressVideo(
221+
input: string | Buffer,
222+
options: CompressOptions = {},
223+
): Promise<CompressResult> {
224+
const startTime = Date.now();
225+
226+
// 1. Resolve input to a Buffer
227+
let inputBuffer: Buffer;
228+
if (typeof input === "string") {
229+
inputBuffer = await downloadUrl(input);
230+
} else {
231+
inputBuffer = input;
232+
}
233+
234+
const originalSize = inputBuffer.length;
235+
log(`Original video size: ${(originalSize / 1024 / 1024).toFixed(2)} MB`);
236+
237+
// 2. Check FFmpeg availability
238+
if (!isFfmpegAvailable()) {
239+
warn("FFmpeg is not available — returning original video uncompressed.");
240+
return {
241+
buffer: inputBuffer,
242+
originalSize,
243+
compressedSize: originalSize,
244+
ratio: 1,
245+
durationMs: Date.now() - startTime,
246+
};
247+
}
248+
249+
// 3. Write input to temp file
250+
const { inputPath, outputPath, passLogPrefix, cleanup } = makeTempDir();
251+
252+
try {
253+
writeFileSync(inputPath, inputBuffer);
254+
255+
// 4. Decide encoding strategy
256+
const useTwoPass = !!options.videoBitrate;
257+
258+
if (useTwoPass) {
259+
log("Running two-pass encode …");
260+
261+
// Pass 1
262+
const pass1Args = buildFfmpegArgs(inputPath, outputPath, options, 1, passLogPrefix);
263+
log("Pass 1:", "ffmpeg", pass1Args.join(" "));
264+
execFileSync("ffmpeg", pass1Args, {
265+
stdio: ["ignore", "ignore", "pipe"],
266+
timeout: 600_000, // 10 min
267+
});
268+
269+
// Pass 2
270+
const pass2Args = buildFfmpegArgs(inputPath, outputPath, options, 2, passLogPrefix);
271+
log("Pass 2:", "ffmpeg", pass2Args.join(" "));
272+
execFileSync("ffmpeg", pass2Args, {
273+
stdio: ["ignore", "ignore", "pipe"],
274+
timeout: 600_000,
275+
});
276+
} else {
277+
log("Running CRF encode …");
278+
const crfArgs = buildFfmpegArgs(inputPath, outputPath, options, "crf");
279+
log("ffmpeg", crfArgs.join(" "));
280+
execFileSync("ffmpeg", crfArgs, {
281+
stdio: ["ignore", "ignore", "pipe"],
282+
timeout: 600_000,
283+
});
284+
}
285+
286+
// 5. Read compressed output
287+
const compressedBuffer = readFileSync(outputPath);
288+
const compressedSize = compressedBuffer.length;
289+
const ratio = compressedSize / originalSize;
290+
const durationMs = Date.now() - startTime;
291+
292+
log(
293+
`Compression complete: ${(compressedSize / 1024 / 1024).toFixed(2)} MB ` +
294+
`(${(ratio * 100).toFixed(1)}% of original) in ${(durationMs / 1000).toFixed(1)}s`,
295+
);
296+
297+
return {
298+
buffer: compressedBuffer,
299+
originalSize,
300+
compressedSize,
301+
ratio,
302+
durationMs,
303+
};
304+
} catch (error) {
305+
const errMsg = error instanceof Error ? error.message : String(error);
306+
warn(`Compression failed: ${errMsg} — returning original video.`);
307+
return {
308+
buffer: inputBuffer,
309+
originalSize,
310+
compressedSize: originalSize,
311+
ratio: 1,
312+
durationMs: Date.now() - startTime,
313+
};
314+
} finally {
315+
// 6. Clean up temp files
316+
cleanup();
317+
}
318+
}
319+
320+
// ---------------------------------------------------------------------------
321+
// getVideoMetadata
322+
// ---------------------------------------------------------------------------
323+
324+
/**
325+
* Get video metadata using ffprobe.
326+
*
327+
* @param input - A file path, URL, or Buffer.
328+
* @returns VideoMetadata with duration, resolution, codecs, etc.
329+
*/
330+
export async function getVideoMetadata(input: string | Buffer): Promise<VideoMetadata> {
331+
if (!isFfprobeAvailable()) {
332+
throw new Error(`${LOG_PREFIX} ffprobe is not available`);
333+
}
334+
335+
let filePath: string;
336+
let cleanupFn: (() => void) | null = null;
337+
338+
if (Buffer.isBuffer(input)) {
339+
const { inputPath, cleanup } = makeTempDir();
340+
writeFileSync(inputPath, input);
341+
filePath = inputPath;
342+
cleanupFn = cleanup;
343+
} else if (input.startsWith("http://") || input.startsWith("https://")) {
344+
// ffprobe can read URLs directly, but downloading is more reliable
345+
const buf = await downloadUrl(input);
346+
const { inputPath, cleanup } = makeTempDir();
347+
writeFileSync(inputPath, buf);
348+
filePath = inputPath;
349+
cleanupFn = cleanup;
350+
} else {
351+
filePath = input;
352+
}
353+
354+
try {
355+
const probeArgs = [
356+
"-v",
357+
"quiet",
358+
"-print_format",
359+
"json",
360+
"-show_format",
361+
"-show_streams",
362+
filePath,
363+
];
364+
365+
const result = execFileSync("ffprobe", probeArgs, {
366+
encoding: "utf-8",
367+
timeout: 30_000,
368+
});
369+
370+
const data = JSON.parse(result);
371+
372+
const videoStream = data.streams?.find(
373+
(s: { codec_type: string }) => s.codec_type === "video",
374+
);
375+
const audioStream = data.streams?.find(
376+
(s: { codec_type: string }) => s.codec_type === "audio",
377+
);
378+
const format = data.format || {};
379+
380+
return {
381+
duration: parseFloat(format.duration || "0"),
382+
width: videoStream?.width || 0,
383+
height: videoStream?.height || 0,
384+
videoCodec: videoStream?.codec_name || "unknown",
385+
audioCodec: audioStream?.codec_name || "unknown",
386+
bitrate: parseInt(format.bit_rate || "0", 10),
387+
fileSize: parseInt(format.size || "0", 10),
388+
};
389+
} finally {
390+
if (cleanupFn) cleanupFn();
391+
}
392+
}

0 commit comments

Comments
 (0)