-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add SocialPost and CropPreview compositions for API rendering #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import React from "react"; | ||
| import { AbsoluteFill, OffthreadVideo } from "remotion"; | ||
|
|
||
| export interface CroppedVideoProps { | ||
| /** URL of the 16:9 source video */ | ||
| videoUrl: string; | ||
| } | ||
|
|
||
| /** | ||
| * Center-crops a 16:9 landscape video to 9:16 portrait. | ||
| * | ||
| * The video is scaled so its height fills the 1280px canvas, | ||
| * then horizontally centered so the subject (who should be centered | ||
| * in the original) stays in frame. | ||
| */ | ||
| export const CroppedVideo: React.FC<CroppedVideoProps> = ({ videoUrl }) => { | ||
| // 16:9 source scaled to fill 1280px height: | ||
| // width = 1280 * (16/9) ≈ 2276px | ||
| // Offset to center: -(2276 - 720) / 2 ≈ -778px | ||
| const scaledWidth = 1280 * (16 / 9); | ||
| const offsetX = -(scaledWidth - 720) / 2; | ||
|
|
||
| return ( | ||
| <AbsoluteFill style={{ backgroundColor: "#000", overflow: "hidden" }}> | ||
| <OffthreadVideo | ||
| src={videoUrl} | ||
| style={{ | ||
| position: "absolute", | ||
| top: 0, | ||
| left: offsetX, | ||
| width: scaledWidth, | ||
| height: 1280, | ||
| objectFit: "cover", | ||
| }} | ||
| /> | ||
| </AbsoluteFill> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import React from "react"; | ||
| import { | ||
| AbsoluteFill, | ||
| OffthreadVideo, | ||
| Audio, | ||
| Sequence, | ||
| staticFile, | ||
| } from "remotion"; | ||
|
|
||
| // Load TikTok Sans Regular — the actual TikTok caption font | ||
| // Guard against server-side execution (FontFace is a browser-only API) | ||
| if (typeof globalThis.FontFace !== "undefined") { | ||
| const fontFace = new FontFace("TikTok Sans", `url(${staticFile("TikTokSans-Regular.ttf")})`); | ||
| fontFace.load().then((f) => { document.fonts.add(f); }).catch(() => {}); | ||
| } | ||
|
|
||
| export interface SocialPostProps { | ||
| /** URL of the 16:9 source video */ | ||
| videoUrl: string; | ||
| /** Filename of the audio in Remotion's public dir (empty = no audio) */ | ||
| audioSrc: string; | ||
| /** Caption text to overlay */ | ||
| captionText: string; | ||
| /** Whether the source video already has audio (lip-sync path) */ | ||
| hasAudio: boolean; | ||
| /** Start offset into the audio file in seconds (default 0) */ | ||
| audioStartSeconds: number; | ||
| } | ||
|
|
||
| /** | ||
| * Full social post composition: | ||
| * 1. Center-crop 16:9 → 9:16 | ||
| * 2. Song audio (if not lip-sync) | ||
| * 3. TikTok-style caption text (white with black stroke, bottom center) | ||
| */ | ||
| export const SocialPost: React.FC<SocialPostProps> = ({ | ||
| videoUrl, | ||
| audioSrc, | ||
| captionText, | ||
| hasAudio, | ||
| audioStartSeconds = 0, | ||
| }) => { | ||
| // Convert audio start offset from seconds to frames (audio is 30fps in Remotion) | ||
| const audioStartFrame = Math.round(audioStartSeconds * 30); | ||
|
|
||
| // Caption visible immediately | ||
| const captionOpacity = 1; | ||
|
|
||
| // Crop math: 16:9 → 9:16 center crop | ||
| const scaledWidth = 1280 * (16 / 9); | ||
| const offsetX = -(scaledWidth - 720) / 2; | ||
|
Comment on lines
+50
to
+51
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major DRY violation — crop math is identical to
Option A — compose ♻️ Proposed fix (Option A)+import { CroppedVideo } from "./CroppedVideo";
export const SocialPost: React.FC<SocialPostProps> = ({ videoUrl, ... }) => {
- const scaledWidth = 1280 * (16 / 9);
- const offsetX = -(scaledWidth - 720) / 2;
return (
<AbsoluteFill style={{ backgroundColor: "#000", overflow: "hidden" }}>
- <OffthreadVideo
- src={videoUrl}
- style={{ position: "absolute", top: 0, left: offsetX, width: scaledWidth, height: 1280, objectFit: "cover" }}
- />
+ <CroppedVideo videoUrl={videoUrl} />
{/* audio and caption layers unchanged */}
</AbsoluteFill>
);
};Option B — extract to a utility if composing the component is impractical: ♻️ Proposed fix (Option B)// src/utils/cropMath.ts
export function getCropStyle(width: number, height: number) {
const scaledWidth = height * (16 / 9);
const offsetX = -(scaledWidth - width) / 2;
return { scaledWidth, offsetX };
}Then use 🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <AbsoluteFill style={{ backgroundColor: "#000", overflow: "hidden" }}> | ||
| {/* Layer 1: Cropped video */} | ||
| <OffthreadVideo | ||
| src={videoUrl} | ||
| style={{ | ||
| position: "absolute", | ||
| top: 0, | ||
| left: offsetX, | ||
| width: scaledWidth, | ||
| height: 1280, | ||
| objectFit: "cover", | ||
| }} | ||
| /> | ||
|
|
||
| {/* Layer 2: Audio (skip if video already has it) */} | ||
| {!hasAudio && audioSrc && ( | ||
| <Sequence from={0}> | ||
| <Audio src={staticFile(audioSrc)} volume={0.85} startFrom={audioStartFrame} /> | ||
| </Sequence> | ||
| )} | ||
|
|
||
| {/* Layer 3: TikTok-style caption — white text, black stroke, bottom center */} | ||
| {captionText && ( | ||
| <div | ||
| style={{ | ||
| position: "absolute", | ||
| bottom: "18%", | ||
| left: 0, | ||
| right: 0, | ||
| display: "flex", | ||
| justifyContent: "center", | ||
| padding: "0 80px", | ||
| opacity: captionOpacity, | ||
| }} | ||
| > | ||
| <p | ||
| style={{ | ||
| color: "#FFFFFF", | ||
| fontFamily: "'TikTok Sans', system-ui, sans-serif", | ||
| fontSize: 46, | ||
| fontWeight: 400, | ||
| textAlign: "center", | ||
| lineHeight: 1.2, | ||
| margin: 0, | ||
| letterSpacing: "-0.02em", | ||
| wordWrap: "break-word", | ||
| maxWidth: "100%", | ||
| // Text outline via text-shadow | ||
| textShadow: [ | ||
| "-4px -4px 0 #000", "4px -4px 0 #000", "-4px 4px 0 #000", "4px 4px 0 #000", | ||
| "0 -4px 0 #000", "0 4px 0 #000", "-4px 0 0 #000", "4px 0 0 #000", | ||
| "-3px -3px 0 #000", "3px -3px 0 #000", "-3px 3px 0 #000", "3px 3px 0 #000", | ||
| "-2px -2px 0 #000", "2px -2px 0 #000", "-2px 2px 0 #000", "2px 2px 0 #000", | ||
| ].join(", "), | ||
| }} | ||
| > | ||
| {captionText} | ||
| </p> | ||
| </div> | ||
| )} | ||
| </AbsoluteFill> | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Font load is not synchronized with Remotion's render pipeline — SSR frames may use the fallback font.
The canonical Remotion pattern for font loading requires calling
delayRender()before the async op andcontinueRender(waitForFont)inside.then()so Remotion waits for the font before screenshotting any frame. Without it, the render starts immediately andTikTok Sansis almost certainly not yet indocument.fontswhen frame 0 is captured — every frame will render in thesystem-uifallback.Additionally,
.catch(() => {})silences errors entirely. If the async task fails and you cannot recover,cancelRender()should be called to cancel the render instead of silently falling back to the wrong font.🐛 Proposed fix
🤖 Prompt for AI Agents