# CameraRig

Pan and zoom toward a world-space point without reflowing the scene.

Page: https://20cuts.com/docs/components/camera-rig

## Installation

Run from your product repository:

```sh
npx -y https://20cuts.com/releases/v0.3.0/20cuts-create-0.3.0.tgz video/20cuts
cd video/20cuts
npm ci
npm run studio
```

The installer includes this component, its source dependencies, runnable examples,
and skills for Codex and Claude Code. If 20cuts is already installed, import the
component from its existing source directory.

## Usage

```tsx
import { CameraRig } from "./system/motion";

export default function VideoScene() {
  return (
    <CameraRig from={{x: 640, y: 360, zoom: 1}} to={{x: 900, y: 340, zoom: 1.7}} at={1.2} duration={1.4}><div style={{position: "absolute", left: 780, top: 280, fontSize: 48}}>The detail</div></CameraRig>
  );
}
```

## Focus on a product detail

Save a 1280×720 product screenshot as public/product.png. The target's x and y
land at the video center; zoom controls scale. Save this as src/ProductZoom.tsx:

```tsx
import { AbsoluteFill, Img, staticFile } from "remotion";
import { CameraRig } from "./system";

const wide = {x: 640, y: 360, zoom: 1};
const detail = {x: 900, y: 340, zoom: 1.7};

export default function ProductZoom() {
  return (
    <AbsoluteFill style={{background: "#fff", overflow: "hidden"}}>
      <CameraRig
        from={wide}
        to={detail}
        at={1}
        duration={1.2}
      >
        <Img
          src={staticFile("product.png")}
          style={{width: 1280, height: 720}}
        />
      </CameraRig>
    </AbsoluteFill>
  );
}
```

To hold the detail and return to the full scene at 4 seconds, replace that scene
with this version. The shared pose keeps the handoff continuous:

```tsx
import { AbsoluteFill, Img, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
import { CameraRig } from "./system";

const wide = {x: 640, y: 360, zoom: 1};
const detail = {x: 900, y: 340, zoom: 1.7};

export default function ProductZoom() {
  const seconds = useCurrentFrame() / useVideoConfig().fps;
  const returning = seconds >= 4;

  return (
    <AbsoluteFill style={{background: "#fff", overflow: "hidden"}}>
      <CameraRig
        from={returning ? detail : wide}
        to={returning ? wide : detail}
        at={returning ? 4 : 1}
        duration={1.2}
      >
        <Img
          src={staticFile("product.png")}
          style={{width: 1280, height: 720}}
        />
      </CameraRig>
    </AbsoluteFill>
  );
}
```

Register this image guide in src/index.tsx:

```tsx
import { Composition, registerRoot } from "remotion";
import ProductZoom from "./ProductZoom";

registerRoot(() => (
  <Composition
    id="ProductZoom"
    component={ProductZoom}
    width={1280}
    height={720}
    fps={30}
    durationInFrames={180}
  />
));
```


## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| children (required) | ReactNode | None | React content to display. |
| from (required) | CameraPose | None | Starting world-space center point and zoom. |
| to (required) | CameraPose | None | Ending world-space center point and zoom. |
| at | number | 0 | Start time in seconds within the current Remotion Sequence. |
| duration | number | 1.2 | Animation duration in seconds. |
| style | CSSProperties | None | Styles applied to the component wrapper. |

Both poses are required. Their x and y coordinates identify the world point placed at the viewport center; zoom must be positive.

Viewport width and height come from useVideoConfig(). CameraRig has no width or height prop and does not reflow its children.

## Complete example

This is the source of the fictional component demonstration. Replace its content
with supported behavior and assets from your product.

```tsx
import React, { type CSSProperties, type ReactNode } from "react";

import { AbsoluteFill } from "remotion";

import { AppFrame, CameraRig } from "./system";

const ink = "#141414", lime = "#b6f36b";

const center: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center" };

const card: CSSProperties = { background: "#fff", border: "1px solid #ddd", borderRadius: 20, padding: 32 };

function Stage({ children, dark = false, style }: {
    children: ReactNode;
    dark?: boolean;
    style?: CSSProperties;
}) {
    return <AbsoluteFill style={{ fontFamily: "Arial, Helvetica, sans-serif", color: dark ? "#fff" : ink, background: dark ? ink : "#f7f7f5", ...center, ...style }}>{children}</AbsoluteFill>;
}

function Dashboard({ compact = false, emphasizeMetric = false }: {
    compact?: boolean;
    emphasizeMetric?: boolean;
}) {
    return <div style={{ padding: compact ? 24 : 36, display: "grid", gap: 26, color: ink }}>
    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}><strong style={{ fontSize: 30, letterSpacing: "-0.035em" }}>Project overview</strong><span style={{ background: lime, padding: "10px 18px", borderRadius: 8, fontSize: 16 }}>New project +</span></div>
    <div style={{ display: "grid", gridTemplateColumns: "repeat(3,1fr)", gap: 16 }}>{[["In progress", "08"], ["Completed", "24"], ["This week", "+12%"]].map(([label, value], i) => <div key={label} style={{ ...card, padding: 20, background: "#fafafa", outline: emphasizeMetric && i === 2 ? `4px solid ${lime}` : undefined }}><div style={{ fontSize: 16, color: "#666" }}>{label}</div><div style={{ fontSize: 42, marginTop: 12, letterSpacing: "-0.05em" }}>{value}</div></div>)}</div>
    <div style={{ display: "flex", height: compact ? 110 : 150, alignItems: "flex-end", gap: 14, borderBottom: "1px solid #ddd" }}>{[24, 42, 35, 68, 54, 78, 96, 74, 84, 100].map((n, i) => <div key={i} style={{ flex: 1, height: `${n}%`, background: i === 9 ? lime : "#e4e5e0", borderRadius: "8px 8px 0 0" }}/>)}</div>
  </div>;
}

export function CameraRigExample() { return <Stage style={{ overflow: "hidden" }}><CameraRig from={{ x: 640, y: 360, zoom: 1 }} to={{ x: 968, y: 317, zoom: 1.8 }} at={1.3} duration={1.6}><div style={{ position: "absolute", left: 120, top: 105 }}><AppFrame width={1040} height={510}><Dashboard emphasizeMetric/></AppFrame></div></CameraRig></Stage>; }
```

Save it as src/CameraRigExample.tsx. This demonstration uses
1280×720, 30fps and 180 frames.
For a separate entry point, replace src/index.tsx with this registration, then
run npm run studio:

```tsx
import { Composition, registerRoot } from "remotion";
import { CameraRigExample } from "./CameraRigExample";

registerRoot(() => (
  <Composition
    id="CameraRigExample"
    component={CameraRigExample}
    width={1280}
    height={720}
    fps={30}
    durationInFrames={180}
  />
));
```

## Source

- https://20cuts.com/animation-system/library/source/motion/direction.tsx
- https://20cuts.com/animation-system/library/source/motion/core.ts

## Agent instructions

Use the installed 20cuts CameraRig to show my product, then focus on one useful detail. Use my actual product UI or a screenshot and its real copy. Make a 1280×720, 30fps, 180-frame Remotion composition. Place the product at its final size before choosing the target. CameraRig x and y are the point in the product scene that should land at the center of the video; zoom is the scale. For a full-frame 1280×720 image, the wide pose is {x: 640, y: 360, zoom: 1}. Measure the center of the detail in that same coordinate space, choose a positive zoom that keeps the crop inside the image, and begin the move at 1 second over 1.2 seconds. Hold the detail until 4 seconds, then return to the wide pose over 1.2 seconds. Use Remotion's frame clock to switch CameraRig from/to/at at the return point while keeping the product mounted. At the handoff, the return's from pose must equal the forward move's to pose. Register the composition, preview the opening, detail, and return frames, and render it. Use the guide's product.png example only after providing the real image in public/product.png.
