Skip to content

feat: add SocialPost and CropPreview compositions for API rendering#3

Open
sidneyswift wants to merge 3 commits intomainfrom
feature/consolidate-compositions
Open

feat: add SocialPost and CropPreview compositions for API rendering#3
sidneyswift wants to merge 3 commits intomainfrom
feature/consolidate-compositions

Conversation

@sidneyswift
Copy link
Copy Markdown
Contributor

@sidneyswift sidneyswift commented Feb 19, 2026

What

Adds two new compositions to the Remotion project that are used by the POST /api/video/render endpoint (PR 222 in api repo) for server-side video rendering via Trigger.dev.

New Compositions

  • SocialPost (720×1280, 9:16 portrait) — Full social media post: center-crops 16:9 video to portrait, overlays song audio, adds TikTok-style captions
  • CropPreview (720×1280, 9:16 portrait) — Simple center-crop preview: 16:9 → 9:16 with no audio or captions

Other Changes

  • Added TikTokSans-Regular.ttf font for caption styling
  • Guarded FontFace API usage with typeof globalThis.FontFace check for Node.js compatibility (required for server-side rendering in Trigger.dev)
  • Updated AGENTS.md with documentation on local vs hosted usage, deployment checklist, composition design guidelines, and roadmap (Option A: pre-built library → Option B: template-driven)

Related PRs

  • api: PR 222 — POST /api/video/render endpoint
  • tasks: PR TBD — render-video Trigger.dev task with Remotion rendering logic

Tested

Successfully rendered a CropPreview composition via the API endpoint on preview deployment — video uploaded to Supabase Storage and accessible via signed URL.

Summary by CodeRabbit

  • New Features

    • Added SocialPost composition for creating social media content with video, audio, and captions.
    • Added CropPreview composition for center-cropping landscape videos to portrait format.
  • Documentation

    • Expanded architecture documentation covering local development and hosted API rendering workflows.
    • Added composition design guidelines, timing configuration, and roadmap for future enhancements.

Move SocialPost and CroppedVideo compositions from the content-creation-app
into the main remotion project. This consolidates all Remotion compositions
into a single registry so the render-video API endpoint can render any
composition by ID.

New compositions:
- SocialPost: Center-crop 16:9→9:16, audio overlay, TikTok-style captions
- CropPreview: Simple center-crop for validation

Also adds TikTokSans-Regular.ttf font for caption rendering.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 19, 2026

📝 Walkthrough

Walkthrough

The changes introduce two new video composition components (CroppedVideo and SocialPost) for generating 9:16 portrait social media videos from 16:9 landscape sources, register them in the root composition, and expand AGENTS.md documentation to describe the dual-role architecture supporting both local development and hosted API rendering workflows.

Changes

Cohort / File(s) Summary
Documentation
AGENTS.md
Expanded architecture description to cover dual usage model (local previews and hosted API rendering), reorganized Video Compositions section with new two-tier structure, added Component Structure, Key Patterns, Timing Configuration, Composition Design Guidelines, and Roadmap sections.
Composition Registration
src/Root.tsx
Added imports and registered two new compositions: SocialPost and CropPreview, each with typed default props (SocialPostProps and CroppedVideoProps).
Video Cropping Component
src/components/CroppedVideo.tsx
Introduced new CroppedVideo component that center-crops 16:9 landscape video to 9:16 portrait frame using calculated offset and scaling, with black background and hidden overflow.
Social Post Component
src/components/SocialPost.tsx
Introduced new SocialPost component composing portrait video with center-crop, optional audio layer (with configurable start timing), and bottom-centered TikTok-style caption overlay with font loading and multi-shadow text outline for legibility.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • sweetmantech

Poem

🐰 Two new frames now bloom so bright,
Nine-to-sixteen, portrait light!
Captions dance with TikTok flair,
Crops and audio with care—
Social posts hop through the air! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding two new compositions (SocialPost and CropPreview) designed for API rendering.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/consolidate-compositions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
src/components/SocialPost.tsx (4)

47-47: Dead variable captionOpacity — remove it.

const captionOpacity = 1 is always 1 and its only use is opacity: captionOpacity, which is equivalent to opacity: 1. There's no animation wired up to it.

♻️ Proposed fix
-  // Caption visible immediately
-  const captionOpacity = 1;
   ...
-            opacity: captionOpacity,
+            opacity: 1,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/SocialPost.tsx` at line 47, Remove the dead constant
captionOpacity from the SocialPost component and replace any usage of opacity:
captionOpacity with the literal 1 (or simply remove the opacity style if default
is fine); specifically delete the const captionOpacity = 1 declaration and
update the style where opacity: captionOpacity is referenced (in the SocialPost
JSX) so no unused variable remains.

70-72: <Sequence from={0}> wrapping <Audio> is a no-op — remove it.

from={0} is the default value for Sequence. The wrapper adds no semantic timing offset; <Audio> with its startFrom prop can stand alone.

♻️ Proposed fix
-      {!hasAudio && audioSrc && (
-        <Sequence from={0}>
-          <Audio src={staticFile(audioSrc)} volume={0.85} startFrom={audioStartFrame} />
-        </Sequence>
-      )}
+      {!hasAudio && audioSrc && (
+        <Audio src={staticFile(audioSrc)} volume={0.85} startFrom={audioStartFrame} />
+      )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/SocialPost.tsx` around lines 70 - 72, The <Sequence from={0}>
wrapper around the Audio element is redundant; remove the Sequence wrapper so
the Audio component stands alone. Locate the JSX where Sequence and Audio are
used (the Sequence component wrapping <Audio src={staticFile(audioSrc)}
volume={0.85} startFrom={audioStartFrame} />) and replace it with the Audio
element by itself, preserving the props (src={staticFile(audioSrc)},
volume={0.85}, startFrom={audioStartFrame}); no other timing changes or imports
should be needed.

56-66: Add muted={!hasAudio} to OffthreadVideo to prevent double-audio.

When hasAudio: false, audio is managed explicitly via the <Audio> component. If the source video unexpectedly contains an audio track, OffthreadVideo will include it, producing double-audio in the render. Making the mute state conditional on hasAudio is a cheap defensive guard.

♻️ Proposed fix
 <OffthreadVideo
   src={videoUrl}
+  muted={!hasAudio}
   style={{ ... }}
 />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/SocialPost.tsx` around lines 56 - 66, OffthreadVideo may play
embedded audio causing double-audio when you also render <Audio>; update the
OffthreadVideo usage to set its muted prop based on hasAudio (i.e.,
muted={!hasAudio}) so the video is muted when audio is handled via the Audio
component; locate the OffthreadVideo component instance in SocialPost (the one
using props videoUrl, offsetX, scaledWidth) and add the conditional muted prop
to it.

44-44: Hardcoded 30 fps in audioStartFrame — use useVideoConfig().fps.

If the composition's fps ever changes (e.g., 60fps preview), audioStartSeconds * 30 produces the wrong frame offset. The comment "audio is 30fps in Remotion" is also a misnomer — frame timing is driven by the composition fps, not a fixed audio rate.

♻️ Proposed fix
-import { AbsoluteFill, OffthreadVideo, Audio, Sequence, staticFile } from "remotion";
+import { AbsoluteFill, OffthreadVideo, Audio, Sequence, staticFile, useVideoConfig } from "remotion";

 export const SocialPost: React.FC<SocialPostProps> = ({ ..., audioStartSeconds = 0 }) => {
+  const { fps } = useVideoConfig();
-  // Convert audio start offset from seconds to frames (audio is 30fps in Remotion)
-  const audioStartFrame = Math.round(audioStartSeconds * 30);
+  // Convert audio start offset from seconds to frames
+  const audioStartFrame = Math.round(audioStartSeconds * fps);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/SocialPost.tsx` at line 44, The code uses a hardcoded 30fps
when computing audioStartFrame (const audioStartFrame =
Math.round(audioStartSeconds * 30)), which breaks when composition fps changes;
replace the magic 30 with the composition fps from useVideoConfig() by calling
useVideoConfig(), destructuring fps, and computing audioStartFrame =
Math.round(audioStartSeconds * fps); also update/remove the misleading comment
about "audio is 30fps in Remotion" so timing reflects the composition fps.
AGENTS.md (1)

49-49: Manual dual-repo sync is an operational risk — consider a CI safeguard.

The requirement to hand-copy components to tasks/src/remotion/ every time one changes here means a missed sync silently deploys stale compositions to the API renderer. Consider adding a CI step (e.g., a diff check or a shared package/symlink) that fails the pipeline if the two copies diverge. At minimum, the deployment checklist in this file is a good stopgap until that automation exists.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` at line 49, The AGENTS.md note highlights a risky manual sync of
remotion compositions to the tasks repo; add an automated safeguard: create a CI
job that compares the composition files in this repo against the duplicate in
the tasks repository (or the shared package if you move them) and fail the
pipeline on any diff, or refactor to a single shared package/monorepo artifact
consumed by both repos and update build steps accordingly; update AGENTS.md to
record the new CI check or shared-package requirement so future PRs must satisfy
the check before merging.
src/components/CroppedVideo.tsx (1)

20-21: Use useVideoConfig() for composition dimensions instead of magic numbers.

1280 and 720 are hardcoded. If this component is ever embedded in a composition with different dimensions, the crop framing will be silently wrong. useVideoConfig() makes the math adaptive at zero cost.

♻️ Proposed fix
-import { AbsoluteFill, OffthreadVideo } from "remotion";
+import { AbsoluteFill, OffthreadVideo, useVideoConfig } from "remotion";

 export const CroppedVideo: React.FC<CroppedVideoProps> = ({ videoUrl }) => {
+  const { width, height } = useVideoConfig();
-  const scaledWidth = 1280 * (16 / 9);
-  const offsetX = -(scaledWidth - 720) / 2;
+  const scaledWidth = height * (16 / 9);
+  const offsetX = -(scaledWidth - width) / 2;
   ...
-          height: 1280,
+          height,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/CroppedVideo.tsx` around lines 20 - 21, Replace the hardcoded
numbers by using useVideoConfig(): import and call useVideoConfig() to get
{width, height}, then compute scaledWidth and offsetX from those values (e.g.
const scaledWidth = width * (16/9) and const offsetX = -(scaledWidth - width)/2
or, if your crop logic expects height, use height in place of the second
operand), replacing the magic 1280 and 720 in the scaledWidth and offsetX
calculations to make the crop adaptive to composition size.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/SocialPost.tsx`:
- Around line 50-51: The crop math for scaledWidth and offsetX is duplicated
from CroppedVideo; remove the duplicated calculations in SocialPost and either
(preferred) compose the existing <CroppedVideo> component for the video layer
instead of re-implementing the math, or extract the shared logic into a small
utility (e.g., getCropStyle(width,height)) that returns scaledWidth and offsetX
and call that from both CroppedVideo and SocialPost; update usages of
scaledWidth and offsetX to use the composed component or the utility.
- Around line 12-15: The font load is currently async and unsynchronized with
Remotion, causing SSR frames to use the fallback; wrap the load with Remotion's
delayRender/continueRender/cancelRender flow: call delayRender() before
creating/loading the FontFace (reference FontFace and
staticFile("TikTokSans-Regular.ttf")), then in the fontFace.load().then(...) add
the loaded font to document.fonts and call continueRender(handle) where handle
is the value returned by delayRender(); in the .catch(...) call
cancelRender(handle) and log the error instead of silencing it so failed font
loads cancel the render and surface the error.

---

Nitpick comments:
In `@AGENTS.md`:
- Line 49: The AGENTS.md note highlights a risky manual sync of remotion
compositions to the tasks repo; add an automated safeguard: create a CI job that
compares the composition files in this repo against the duplicate in the tasks
repository (or the shared package if you move them) and fail the pipeline on any
diff, or refactor to a single shared package/monorepo artifact consumed by both
repos and update build steps accordingly; update AGENTS.md to record the new CI
check or shared-package requirement so future PRs must satisfy the check before
merging.

In `@src/components/CroppedVideo.tsx`:
- Around line 20-21: Replace the hardcoded numbers by using useVideoConfig():
import and call useVideoConfig() to get {width, height}, then compute
scaledWidth and offsetX from those values (e.g. const scaledWidth = width *
(16/9) and const offsetX = -(scaledWidth - width)/2 or, if your crop logic
expects height, use height in place of the second operand), replacing the magic
1280 and 720 in the scaledWidth and offsetX calculations to make the crop
adaptive to composition size.

In `@src/components/SocialPost.tsx`:
- Line 47: Remove the dead constant captionOpacity from the SocialPost component
and replace any usage of opacity: captionOpacity with the literal 1 (or simply
remove the opacity style if default is fine); specifically delete the const
captionOpacity = 1 declaration and update the style where opacity:
captionOpacity is referenced (in the SocialPost JSX) so no unused variable
remains.
- Around line 70-72: The <Sequence from={0}> wrapper around the Audio element is
redundant; remove the Sequence wrapper so the Audio component stands alone.
Locate the JSX where Sequence and Audio are used (the Sequence component
wrapping <Audio src={staticFile(audioSrc)} volume={0.85}
startFrom={audioStartFrame} />) and replace it with the Audio element by itself,
preserving the props (src={staticFile(audioSrc)}, volume={0.85},
startFrom={audioStartFrame}); no other timing changes or imports should be
needed.
- Around line 56-66: OffthreadVideo may play embedded audio causing double-audio
when you also render <Audio>; update the OffthreadVideo usage to set its muted
prop based on hasAudio (i.e., muted={!hasAudio}) so the video is muted when
audio is handled via the Audio component; locate the OffthreadVideo component
instance in SocialPost (the one using props videoUrl, offsetX, scaledWidth) and
add the conditional muted prop to it.
- Line 44: The code uses a hardcoded 30fps when computing audioStartFrame (const
audioStartFrame = Math.round(audioStartSeconds * 30)), which breaks when
composition fps changes; replace the magic 30 with the composition fps from
useVideoConfig() by calling useVideoConfig(), destructuring fps, and computing
audioStartFrame = Math.round(audioStartSeconds * fps); also update/remove the
misleading comment about "audio is 30fps in Remotion" so timing reflects the
composition fps.

Comment on lines +12 to +15
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(() => {});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 and continueRender(waitForFont) inside .then() so Remotion waits for the font before screenshotting any frame. Without it, the render starts immediately and TikTok Sans is almost certainly not yet in document.fonts when frame 0 is captured — every frame will render in the system-ui fallback.

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
-import { AbsoluteFill, OffthreadVideo, Audio, Sequence, staticFile } from "remotion";
+import { AbsoluteFill, OffthreadVideo, Audio, Sequence, staticFile, delayRender, continueRender, cancelRender } from "remotion";

-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(() => {});
-}
+if (typeof globalThis.FontFace !== "undefined") {
+  const waitForFont = delayRender("Loading TikTok Sans");
+  const fontFace = new FontFace("TikTok Sans", `url('${staticFile("TikTokSans-Regular.ttf")}') format('truetype')`);
+  fontFace
+    .load()
+    .then((f) => { document.fonts.add(f); continueRender(waitForFont); })
+    .catch((err) => cancelRender(err));
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/SocialPost.tsx` around lines 12 - 15, The font load is
currently async and unsynchronized with Remotion, causing SSR frames to use the
fallback; wrap the load with Remotion's delayRender/continueRender/cancelRender
flow: call delayRender() before creating/loading the FontFace (reference
FontFace and staticFile("TikTokSans-Regular.ttf")), then in the
fontFace.load().then(...) add the loaded font to document.fonts and call
continueRender(handle) where handle is the value returned by delayRender(); in
the .catch(...) call cancelRender(handle) and log the error instead of silencing
it so failed font loads cancel the render and surface the error.

Comment on lines +50 to +51
const scaledWidth = 1280 * (16 / 9);
const offsetX = -(scaledWidth - 720) / 2;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

DRY violation — crop math is identical to CroppedVideo.tsx.

scaledWidth and offsetX are copy-pasted from CroppedVideo.tsx lines 20-21. Either compose the existing <CroppedVideo> component for the video layer, or extract the math to a shared utility. As per coding guidelines: "Extract shared logic into utilities (DRY)."

Option A — compose <CroppedVideo> (preferred, eliminates all duplication):

♻️ 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 getCropStyle(720, 1280) in both components.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/SocialPost.tsx` around lines 50 - 51, The crop math for
scaledWidth and offsetX is duplicated from CroppedVideo; remove the duplicated
calculations in SocialPost and either (preferred) compose the existing
<CroppedVideo> component for the video layer instead of re-implementing the
math, or extract the shared logic into a small utility (e.g.,
getCropStyle(width,height)) that returns scaledWidth and offsetX and call that
from both CroppedVideo and SocialPost; update usages of scaledWidth and offsetX
to use the composed component or the utility.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant