Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 102 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,45 +27,129 @@ npm run build:showcase # Render CommitShowcase to out/commit-showcase.mp4

## Architecture

This is a Remotion 4.x project for creating marketing videos for the Recoup platform.
This is a Remotion 4.x project that serves as the **central composition library** for all Recoup video rendering — both local development previews and hosted API rendering via `POST /api/video/render`.

### Video Compositions
## How Compositions Are Used

Three compositions are defined in `src/Root.tsx`:
Compositions defined here are used in two ways:

1. **UpdatesAnnouncement** (1080x1080) - Weekly changelog video showing commits across repos
### 1. Local Usage (Development & Preview)

Run `npm run dev` to open Remotion Studio in the browser. You can preview any registered composition, tweak `inputProps`, and render locally. This is the standard Remotion development workflow — useful for building and testing new compositions before deploying them.

### 2. Hosted Usage (API Rendering via Trigger.dev)

The `POST /api/video/render` endpoint (in the `api` repo) triggers a background task on Trigger.dev (in the `tasks` repo) that renders any registered composition server-side. The flow:

1. Caller sends `POST /api/video/render` with `compositionId`, `inputProps`, dimensions, fps, etc.
2. API authenticates the request and triggers the `render-video` Trigger.dev task
3. The task bundles the Remotion project, selects the composition by ID, and renders it with `renderMedia()`
4. The rendered `.mp4` is uploaded to Supabase Storage and a signed URL (7-day expiry) is returned

**Important:** The `tasks` repo has its own copy of the compositions in `tasks/src/remotion/`. When you add or change a composition here, you must also update the copy in `tasks/src/remotion/` and redeploy the task worker (`cd tasks && pnpm run deploy:trigger-prod`).

### Deployment Checklist (Adding a New Composition)

1. Create the component in `src/components/YourComposition.tsx`
2. Register it as a `<Composition>` in `src/Root.tsx` with default props
3. Test locally with `npm run dev`
4. Copy the component to `tasks/src/remotion/components/YourComposition.tsx`
5. Register it in `tasks/src/remotion/Root.tsx` (mirrors this repo's Root.tsx)
6. Deploy the task worker: `cd tasks && pnpm run deploy:trigger-prod`
7. Now `POST /api/video/render` with `compositionId: "YourComposition"` will work

## Video Compositions

Five compositions are defined in `src/Root.tsx`:

### Internal / Marketing Compositions

1. **UpdatesAnnouncement** (1080x1080) — Weekly changelog video showing commits across repos
- Uses `@remotion/transitions` for slide/fade effects between slides
- Sequence: Intro → Category headers → Individual commits → Outro

2. **CommitShowcase** (1280x1000) - Daily commit feed in a chat UI mockup
2. **CommitShowcase** (1280x1000) Daily commit feed in a chat UI mockup
- Simulates the Recoup chat interface with commits appearing as messages
- 3D perspective rotation effect throughout the video
- Ends with branded CTA overlay

3. **RecoupHomePage** (1280x1000) - Static mockup of the Recoup chat homepage
3. **RecoupHomePage** (1280x1000) — Static mockup of the Recoup chat homepage

### Content Creation Compositions (API-renderable)

These are used by the content creation pipeline and are available via `POST /api/video/render`:

4. **SocialPost** (720x1280, 9:16 portrait) — Full social media post composition
- Center-crops 16:9 source video to 9:16 portrait
- Overlays song audio (skipped in lip-sync mode when `hasAudio: true`)
- TikTok-style caption text (white with black stroke, bottom center)
- Uses TikTok Sans font from `public/TikTokSans-Regular.ttf`
- `inputProps`: `videoUrl`, `audioSrc`, `captionText`, `hasAudio`, `audioStartSeconds`

### Component Structure
5. **CropPreview** (720x1280, 9:16 portrait) — Simple center-crop preview
- Center-crops 16:9 landscape video to 9:16 portrait
- No audio, no captions — just the visual crop
- Useful for previewing how a video will look in portrait before full rendering
- `inputProps`: `videoUrl`

- `src/UpdatesAnnouncement.tsx` - Main composition using TransitionSeries
- `src/CommitShowcase.tsx` - Chat-style commit feed with typing animations
- `src/components/` - Reusable slide components:
- `IntroSlide.tsx`, `OutroSlide.tsx` - Bookend slides
- `CategorySlide.tsx` - Repository header (e.g., "Recoup-Chat")
- `CommitSlide.tsx` - Individual commit display (used in UpdatesAnnouncement)
- `CommitMessage.tsx` - Chat-style commit message (used in CommitShowcase)
- `RecoupHomePage.tsx` - Static homepage mockup
## Component Structure

### Key Patterns
- `src/Root.tsx` — Registers all compositions
- `src/UpdatesAnnouncement.tsx` — Main marketing composition using TransitionSeries
- `src/CommitShowcase.tsx` — Chat-style commit feed with typing animations
- `src/components/` — Reusable components:
- `SocialPost.tsx` — Full social post with crop + audio + captions
- `CroppedVideo.tsx` — Simple 16:9 → 9:16 center crop
- `IntroSlide.tsx`, `OutroSlide.tsx` — Bookend slides for marketing videos
- `CategorySlide.tsx` — Repository header (e.g., "Recoup-Chat")
- `CommitSlide.tsx` — Individual commit display (used in UpdatesAnnouncement)
- `CommitMessage.tsx` — Chat-style commit message (used in CommitShowcase)
- `RecoupHomePage.tsx` — Static homepage mockup

## Key Patterns

- Commit data lives in `src/data/todayCommits.ts` as typed objects with `hash`, `message`, `type`, and `repo` fields
- Type badges use color maps defined per component (`typeColors`, `categoryColors`)
- Brand color: `#345A5D`
- All animations use Remotion's `spring()` and `interpolate()` utilities
- Static assets (logos) are in `public/` and loaded via `staticFile()`
- Static assets (logos, fonts) are in `public/` and loaded via `staticFile()`
- Browser-only APIs like `FontFace` must be guarded with `typeof globalThis.FontFace !== "undefined"` for Node.js compatibility (server-side rendering in Trigger.dev)

### Timing Configuration
## Timing Configuration

Frame-based timing is defined in `src/Root.tsx`:
- FPS: 30
- Durations are calculated in frames (e.g., `2 * FPS` for 2 seconds)
- Transition duration: 15 frames

## Composition Design Guidelines

When building new compositions, follow these rules so they work with both local preview AND the hosted render API:

1. **All customization through `inputProps`** — compositions must accept everything they need (media URLs, text, colors, toggles) as props. No hardcoded artist-specific data.
2. **Export the props interface** — every composition needs an exported `YourCompositionProps` type so `Root.tsx` can use `satisfies` for type-safe default props.
3. **Guard browser-only APIs** — wrap `FontFace`, `document`, `window` calls with `typeof globalThis.X !== "undefined"` checks. The composition runs in Node.js (headless Chrome) during API renders.
4. **Use `OffthreadVideo` not `Video`** — `OffthreadVideo` is required for server-side rendering.
5. **Keep media external** — pass media URLs as props. Don't bundle large assets into the composition. Fonts in `public/` are fine.

## Roadmap: Expanding the Composition Library

### Current: Option A — Pre-built Composition Library

We build and maintain a library of compositions. Users select from available compositions (by `compositionId`) and customize via `inputProps`. Examples of future compositions:

- **LyricVideo** — Full-screen video with word-by-word synced lyrics
- **PhotoSlideshow** — Ken Burns effect across multiple images with music
- **SplitScreen** — Side-by-side or top-bottom video comparison
- **TextOverlay** — Animated text on solid/gradient backgrounds
- **BeforeAfter** — Transition wipe between two clips

Each composition is a React component with typed `inputProps` for customization (colors, fonts, text, media URLs, timing). The trade-off: users depend on us to build new styles.

### Future: Option B — Template-Driven Compositions

Build ONE flexible composition that reads a JSON template config defining the layout, effects, text positions, and transitions. Users create **templates** (JSON), not code. Like how Canva works — the rendering engine is fixed, but templates are user-created.

This is similar to how the content-creation-app's `template.json` already works — it defines image prompts, video moods, caption guides, etc. The composition would interpret that JSON to produce different visual styles from a single codebase.

This approach eliminates the need for code deployments when adding new styles, but requires upfront investment in a flexible composition engine.
Binary file added public/TikTokSans-Regular.ttf
Binary file not shown.
28 changes: 28 additions & 0 deletions src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Composition } from "remotion";
import { UpdatesAnnouncement, UpdatesAnnouncementProps } from "./UpdatesAnnouncement";
import { RecoupHomePage } from "./components/RecoupHomePage";
import { CommitShowcase, CommitShowcaseProps } from "./CommitShowcase";
import { SocialPost, SocialPostProps } from "./components/SocialPost";
import { CroppedVideo, CroppedVideoProps } from "./components/CroppedVideo";
import { todayCommits } from "./data/todayCommits";

const FPS = 30;
Expand Down Expand Up @@ -102,6 +104,32 @@ export const RemotionRoot = () => {
userName: "Black Sabbath",
} satisfies CommitShowcaseProps}
/>
<Composition
id="SocialPost"
component={SocialPost}
durationInFrames={8 * FPS}
fps={FPS}
width={720}
height={1280}
defaultProps={{
videoUrl: "https://example.com/placeholder.mp4",
audioSrc: "",
captionText: "",
hasAudio: false,
audioStartSeconds: 0,
} satisfies SocialPostProps}
/>
<Composition
id="CropPreview"
component={CroppedVideo}
durationInFrames={8 * FPS}
fps={FPS}
width={720}
height={1280}
defaultProps={{
videoUrl: "https://example.com/placeholder.mp4",
} satisfies CroppedVideoProps}
/>
</>
);
};
38 changes: 38 additions & 0 deletions src/components/CroppedVideo.tsx
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>
);
};
116 changes: 116 additions & 0 deletions src/components/SocialPost.tsx
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(() => {});
}
Comment on lines +12 to +15
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.


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
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.


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>
);
};