├── .env.example
├── .eslintrc.json
├── .gitignore
├── README.md
├── components
├── AlignEnd.tsx
├── Button
│ ├── Button.tsx
│ └── styles.module.css
├── Container.tsx
├── DownloadButton.tsx
├── Error.tsx
├── Input.tsx
├── ProgressBar.tsx
├── RenderControls.tsx
├── Spacing.tsx
├── Spinner
│ ├── Spinner.tsx
│ └── styles.module.css
└── Tips
│ ├── Tips.tsx
│ └── styles.module.css
├── config.mjs
├── deploy.mjs
├── helpers
├── api-response.ts
└── use-rendering.ts
├── lambda
└── api.ts
├── next.config.js
├── package-lock.json
├── package.json
├── pages
├── _app.tsx
├── api
│ ├── github.ts
│ └── lambda
│ │ ├── progress.ts
│ │ └── render.ts
└── index.tsx
├── public
├── favicon.ico
└── vercel.svg
├── remotion.config.ts
├── remotion
├── MyComp
│ ├── Main.tsx
│ ├── Tile.tsx
│ └── types.ts
├── Root.tsx
└── index.ts
├── styles
└── global.css
├── tsconfig.json
├── types
├── constants.ts
└── schema.ts
└── vercel.json
/.env.example:
--------------------------------------------------------------------------------
1 | REMOTION_AWS_ACCESS_KEY_ID=""
2 | REMOTION_AWS_SECRET_ACCESS_KEY=""
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next",
3 | "plugins": ["@remotion"],
4 | "overrides": [
5 | {
6 | "files": ["remotion/**/*.{ts,tsx}"],
7 | "extends": ["plugin:@remotion/recommended"]
8 | }
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 | out/
38 | .env
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | This is a Next.js template for building programmatic video apps, with [`@remotion/player`](https://remotion.dev/player) and [`@remotion/lambda`](https://remotion.dev/lambda) built in.
6 |
7 | This template uses the Next.js Pages directory. There is a [App directory version](https://github.com/remotion-dev/template-next-app-dir) of this template available.
8 |
9 |
10 |
11 | ## Getting Started
12 |
13 | [Use this template](https://github.com/new?template_name=template-next-pages-dir&template_owner=remotion-dev) to clone it into your GitHub account. Run
14 |
15 | ```
16 | npm i
17 | ```
18 |
19 | afterwards. Alternatively, use this command to scaffold a project:
20 |
21 | ```
22 | npx create-video@latest --next-pages-dir
23 | ```
24 |
25 | ## Commands
26 |
27 | Start the Next.js dev server:
28 |
29 | ```
30 | npm run dev
31 | ```
32 |
33 | Open the Remotion Studio:
34 |
35 | ```
36 | npm run remotion
37 | ```
38 |
39 | The following script will set up your Remotion Bundle and Lambda function on AWS:
40 |
41 | ```
42 | node deploy.mjs
43 | ```
44 |
45 | You should run this script after:
46 |
47 | - changing the video template
48 | - changing `config.mjs`
49 | - upgrading Remotion to a newer version
50 |
51 | ## Set up rendering on AWS Lambda
52 |
53 | This template supports rendering the videos via [Remotion Lambda](https://remotion.dev/lambda).
54 |
55 | 1. Copy the `.env.example` file to `.env` and fill in the values.
56 | Complete the [Lambda setup guide](https://www.remotion.dev/docs/lambda/setup) to get your AWS credentials.
57 |
58 | 1. Edit the `config.mjs` file to your desired Lambda settings.
59 |
60 | 1. Run `node deploy.mjs` to deploy your Lambda function and Remotion Bundle.
61 |
62 | ## Docs
63 |
64 | Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals).
65 |
66 | ## Help
67 |
68 | We provide help on our [Discord server](https://remotion.dev/discord).
69 |
70 | ## Issues
71 |
72 | Found an issue with Remotion? [File an issue here](https://remotion.dev/issue).
73 |
74 | ## License
75 |
76 | Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md).
77 |
--------------------------------------------------------------------------------
/components/AlignEnd.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const container: React.CSSProperties = {
4 | alignSelf: "flex-end",
5 | };
6 |
7 | export const AlignEnd: React.FC<{
8 | children: React.ReactNode;
9 | }> = ({ children }) => {
10 | return
{children}
;
11 | };
12 |
--------------------------------------------------------------------------------
/components/Button/Button.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from "react";
2 | import { Spacing } from "../Spacing";
3 | import { Spinner } from "../Spinner/Spinner";
4 | import styles from "./styles.module.css";
5 |
6 | const ButtonForward: React.ForwardRefRenderFunction<
7 | HTMLButtonElement,
8 | {
9 | onClick?: () => void;
10 | disabled?: boolean;
11 | children: React.ReactNode;
12 | loading?: boolean;
13 | secondary?: boolean;
14 | }
15 | > = ({ onClick, disabled, children, loading, secondary }, ref) => {
16 | return (
17 |
34 | );
35 | };
36 |
37 | export const Button = forwardRef(ButtonForward);
38 |
--------------------------------------------------------------------------------
/components/Button/styles.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | border: 1px solid var(--foreground);
3 | border-radius: var(--geist-border-radius);
4 | background-color: var(--foreground);
5 | color: var(--background);
6 | padding-left: var(--geist-half-pad);
7 | padding-right: var(--geist-half-pad);
8 | height: 40px;
9 | font-family: var(--geist-font);
10 | font-weight: 500;
11 | transition: all 0.15s ease;
12 | display: inline-flex;
13 | flex-direction: row;
14 | align-items: center;
15 | appearance: none;
16 | font-size: 14px;
17 | }
18 |
19 | .secondarybutton {
20 | background-color: var(--background);
21 | color: var(--foreground);
22 | border-color: var(--unfocused-border-color);
23 | }
24 |
25 | .button:hover {
26 | background-color: var(--background);
27 | cursor: pointer;
28 | color: var(--foreground);
29 | border-color: var(--focused-border-color);
30 | }
31 |
32 | .button:disabled {
33 | background-color: var(--button-disabled-color);
34 | color: var(--disabled-text-color);
35 | border-color: var(--unfocused-border-color);
36 | cursor: not-allowed
37 | }
38 |
--------------------------------------------------------------------------------
/components/Container.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const inputContainer: React.CSSProperties = {
4 | border: "1px solid var(--unfocused-border-color)",
5 | padding: "var(--geist-pad)",
6 | borderRadius: "var(--geist-border-radius)",
7 | backgroundColor: "var(--background)",
8 | display: "flex",
9 | flexDirection: "column",
10 | };
11 |
12 | export const InputContainer: React.FC<{
13 | children: React.ReactNode;
14 | }> = ({ children }) => {
15 | return {children}
;
16 | };
17 |
--------------------------------------------------------------------------------
/components/DownloadButton.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import React from "react";
3 | import { State } from "../helpers/use-rendering";
4 | import { Button } from "./Button/Button";
5 | import { Spacing } from "./Spacing";
6 |
7 | const light: React.CSSProperties = {
8 | opacity: 0.6,
9 | };
10 |
11 | const link: React.CSSProperties = {
12 | textDecoration: "none",
13 | };
14 |
15 | const row: React.CSSProperties = {
16 | display: "flex",
17 | flexDirection: "row",
18 | };
19 |
20 | const Megabytes: React.FC<{
21 | sizeInBytes: number;
22 | }> = ({ sizeInBytes }) => {
23 | const megabytes = Intl.NumberFormat("en", {
24 | notation: "compact",
25 | style: "unit",
26 | unit: "byte",
27 | unitDisplay: "narrow",
28 | }).format(sizeInBytes);
29 | return {megabytes};
30 | };
31 |
32 | export const DownloadButton: React.FC<{
33 | state: State;
34 | undo: () => void;
35 | }> = ({ state, undo }) => {
36 | if (state.status === "rendering") {
37 | return ;
38 | }
39 |
40 | if (state.status !== "done") {
41 | throw new Error("Download button should not be rendered when not done");
42 | }
43 |
44 | return (
45 |
46 |
49 |
50 |
51 |
56 |
57 |
58 | );
59 | };
60 |
61 | const UndoIcon: React.FC = () => {
62 | return (
63 |
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/components/Error.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const container: React.CSSProperties = {
4 | color: "var(--geist-error)",
5 | fontFamily: "var(--geist-font)",
6 | paddingTop: "var(--geist-half-pad)",
7 | paddingBottom: "var(--geist-half-pad)",
8 | };
9 |
10 | const icon: React.CSSProperties = {
11 | height: 20,
12 | verticalAlign: "text-bottom",
13 | marginRight: 6,
14 | };
15 |
16 | export const ErrorComp: React.FC<{
17 | message: string;
18 | }> = ({ message }) => {
19 | return (
20 |
21 |
35 |
Error: {message}
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/components/Input.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 |
3 | const textarea: React.CSSProperties = {
4 | resize: "none",
5 | lineHeight: 1.7,
6 | display: "block",
7 | width: "100%",
8 | borderRadius: "var(--geist-border-radius)",
9 | backgroundColor: "var(--background)",
10 | padding: "var(--geist-half-pad)",
11 | color: "var(--foreground)",
12 | fontSize: 14,
13 | };
14 |
15 | export const Input: React.FC<{
16 | text: string;
17 | setText: React.Dispatch>;
18 | disabled?: boolean;
19 | }> = ({ text, setText, disabled }) => {
20 | const onChange: React.ChangeEventHandler = useCallback(
21 | (e) => {
22 | setText(e.currentTarget.value);
23 | },
24 | [setText]
25 | );
26 |
27 | return (
28 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/components/ProgressBar.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from "react";
2 |
3 | export const ProgressBar: React.FC<{
4 | progress: number;
5 | }> = ({ progress }) => {
6 | const style: React.CSSProperties = useMemo(() => {
7 | return {
8 | width: "100%",
9 | height: 10,
10 | borderRadius: 5,
11 | appearance: "none",
12 | backgroundColor: "var(--unfocused-border-color)",
13 | marginTop: 10,
14 | marginBottom: 25,
15 | };
16 | }, []);
17 |
18 | const fill: React.CSSProperties = useMemo(() => {
19 | return {
20 | backgroundColor: "var(--foreground)",
21 | height: 10,
22 | borderRadius: 5,
23 | transition: "width 0.1s ease-in-out",
24 | width: `${progress * 100}%`,
25 | };
26 | }, [progress]);
27 |
28 | return (
29 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/components/RenderControls.tsx:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { useRendering } from "../helpers/use-rendering";
3 | import { CompositionProps, COMP_NAME } from "../types/constants";
4 | import { AlignEnd } from "./AlignEnd";
5 | import { Button } from "./Button/Button";
6 | import { InputContainer } from "./Container";
7 | import { DownloadButton } from "./DownloadButton";
8 | import { ErrorComp } from "./Error";
9 | import { Input } from "./Input";
10 | import { ProgressBar } from "./ProgressBar";
11 | import { Spacing } from "./Spacing";
12 |
13 | export const RenderControls: React.FC<{
14 | text: string;
15 | setText: React.Dispatch>;
16 | inputProps: z.infer;
17 | }> = ({ text, setText, inputProps }) => {
18 | const { renderMedia, state, undo } = useRendering(COMP_NAME, inputProps);
19 |
20 | return (
21 |
22 | {state.status === "init" ||
23 | state.status === "invoking" ||
24 | state.status === "error" ? (
25 | <>
26 |
31 |
32 |
33 |
40 |
41 | {state.status === "error" ? (
42 |
43 | ) : null}
44 | >
45 | ) : null}
46 | {state.status === "rendering" || state.status === "done" ? (
47 | <>
48 |
51 |
52 |
53 |
54 |
55 | >
56 | ) : null}
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/components/Spacing.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const Spacing: React.FC = () => {
4 | return (
5 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/components/Spinner/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from "react";
2 | import { makeRect } from "@remotion/shapes";
3 | import { translatePath } from "@remotion/paths";
4 | import styles from "./styles.module.css";
5 |
6 | const viewBox = 100;
7 | const lines = 12;
8 | const width = viewBox * 0.08;
9 |
10 | const { path } = makeRect({
11 | height: viewBox * 0.24,
12 | width,
13 | cornerRadius: width / 2,
14 | });
15 |
16 | const translated = translatePath(path, viewBox / 2 - width / 2, viewBox * 0.03);
17 |
18 | export const Spinner: React.FC<{
19 | size: number;
20 | }> = ({ size }) => {
21 | const style = useMemo(() => {
22 | return {
23 | width: size,
24 | height: size,
25 | };
26 | }, [size]);
27 |
28 | return (
29 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/components/Spinner/styles.module.css:
--------------------------------------------------------------------------------
1 | @keyframes spinner {
2 | 0% {
3 | opacity: 1;
4 | }
5 | 100% {
6 | opacity: 0.15;
7 | }
8 | }
9 |
10 | .line {
11 | animation: spinner 1.2s linear infinite;
12 | }
13 |
--------------------------------------------------------------------------------
/components/Tips/Tips.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styles from "./styles.module.css";
3 |
4 | const titlerow: React.CSSProperties = {
5 | display: "flex",
6 | flexDirection: "row",
7 | alignItems: "center",
8 | justifyContent: "flex-start",
9 | };
10 |
11 | const titlestyle: React.CSSProperties = {
12 | marginBottom: "0.75em",
13 | marginTop: "0.75em",
14 | color: "var(--foreground)",
15 | };
16 |
17 | const a: React.CSSProperties = {
18 | textDecoration: "none",
19 | color: "inherit",
20 | flex: 1,
21 | };
22 |
23 | const Tip: React.FC<{
24 | title: React.ReactNode;
25 | description: React.ReactNode;
26 | href: string;
27 | }> = ({ title, description, href }) => {
28 | return (
29 |
30 |
31 |
32 |
{title}
33 |
34 |
40 |
41 |
{description}
42 |
43 |
44 | );
45 | };
46 |
47 | export const Tips: React.FC = () => {
48 | return (
49 |
50 |
55 |
60 |
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/components/Tips/styles.module.css:
--------------------------------------------------------------------------------
1 | .row {
2 | display: flex;
3 | flex-direction: row;
4 | font-family: Inter;
5 | }
6 |
7 | .item {
8 | cursor: pointer;
9 | transition: transform 0.2s ease-in-out;
10 | padding: 10px;
11 | }
12 |
13 | .icon {
14 | opacity: 0;
15 | transition: opacity 0.2s ease-in-out;
16 | }
17 |
18 | .item:hover {
19 | transform: translateY(-2px);
20 | .icon {
21 | opacity: 1;
22 | }
23 | }
24 |
25 | .p {
26 | font-size: 14px;
27 | margin-top: 0;
28 | line-height: 1.5;
29 | color: var(--subtitle);
30 | }
31 |
32 | .flex {
33 | flex: 1;
34 | }
35 |
36 | @media (max-width: 768px) {
37 | .row {
38 | flex-direction: column;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/config.mjs:
--------------------------------------------------------------------------------
1 | import { VERSION } from "remotion/version";
2 |
3 | /**
4 | * Use autocomplete to get a list of available regions.
5 | * @type {import('@remotion/lambda').AwsRegion}
6 | */
7 | export const REGION = "us-east-1";
8 |
9 | export const SITE_NAME = "contributor-graph";
10 | export const RAM = 3009;
11 | export const DISK = 2048;
12 | export const TIMEOUT = 240;
13 |
--------------------------------------------------------------------------------
/deploy.mjs:
--------------------------------------------------------------------------------
1 | import {
2 | deployFunction,
3 | deploySite,
4 | getOrCreateBucket,
5 | } from "@remotion/lambda";
6 | import dotenv from "dotenv";
7 | import path from "path";
8 | import { RAM, REGION, SITE_NAME, TIMEOUT } from "./config.mjs";
9 |
10 | console.log("Selected region:", REGION);
11 | dotenv.config();
12 |
13 | if (!process.env.AWS_ACCESS_KEY_ID && !process.env.REMOTION_AWS_ACCESS_KEY_ID) {
14 | console.log(
15 | 'The environment variable "REMOTION_AWS_ACCESS_KEY_ID" is not set.'
16 | );
17 | console.log("Lambda renders were not set up.");
18 | console.log(
19 | "Complete the Lambda setup: at https://www.remotion.dev/docs/lambda/setup"
20 | );
21 | process.exit(0);
22 | }
23 | if (
24 | !process.env.AWS_SECRET_ACCESS_KEY &&
25 | !process.env.REMOTION_AWS_SECRET_ACCESS_KEY
26 | ) {
27 | console.log(
28 | 'The environment variable "REMOTION_REMOTION_AWS_SECRET_ACCESS_KEY" is not set.'
29 | );
30 | console.log("Lambda renders were not set up.");
31 | console.log(
32 | "Complete the Lambda setup: at https://www.remotion.dev/docs/lambda/setup"
33 | );
34 | process.exit(0);
35 | }
36 |
37 | process.stdout.write("Deploying Lambda function... ");
38 |
39 | const { functionName, alreadyExisted: functionAlreadyExisted } =
40 | await deployFunction({
41 | createCloudWatchLogGroup: true,
42 | memorySizeInMb: RAM,
43 | region: REGION,
44 | timeoutInSeconds: TIMEOUT,
45 | architecture: "arm64",
46 | });
47 | console.log(
48 | functionName,
49 | functionAlreadyExisted ? "(already existed)" : "(created)"
50 | );
51 |
52 | process.stdout.write("Ensuring bucket... ");
53 | const { bucketName, alreadyExisted: bucketAlreadyExisted } =
54 | await getOrCreateBucket({
55 | region: REGION,
56 | });
57 | console.log(
58 | bucketName,
59 | bucketAlreadyExisted ? "(already existed)" : "(created)"
60 | );
61 |
62 | process.stdout.write("Deploying site... ");
63 | const { siteName } = await deploySite({
64 | bucketName,
65 | entryPoint: path.join(process.cwd(), "remotion", "index.ts"),
66 | siteName: SITE_NAME,
67 | region: REGION,
68 | });
69 |
70 | console.log(siteName);
71 |
72 | console.log();
73 | console.log("You now have everything you need to render videos!");
74 | console.log("Re-run this command when:");
75 | console.log(" 1) you changed the video template");
76 | console.log(" 2) you changed config.mjs");
77 | console.log(" 3) you upgraded Remotion to a newer version");
78 |
--------------------------------------------------------------------------------
/helpers/api-response.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 | import { z, ZodType } from "zod";
3 |
4 | export type ApiResponse =
5 | | {
6 | type: "error";
7 | message: string;
8 | }
9 | | {
10 | type: "success";
11 | data: Res;
12 | };
13 |
14 | export const executeApi =
15 | (
16 | schema: Req,
17 | handler: (
18 | req: NextApiRequest,
19 | body: z.infer,
20 | res: NextApiResponse>
21 | ) => Promise
22 | ) =>
23 | async (req: NextApiRequest, res: NextApiResponse>) => {
24 | try {
25 | const parsed = schema.parse(req.body);
26 | const data = await handler(req, parsed, res);
27 | res.status(200).json({
28 | type: "success",
29 | data: data,
30 | });
31 | } catch (err) {
32 | res.status(500).json({ type: "error", message: (err as Error).message });
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/helpers/use-rendering.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { useCallback, useMemo, useState } from "react";
3 | import { getProgress, renderVideo } from "../lambda/api";
4 | import { CompositionProps } from "../types/constants";
5 |
6 | export type State =
7 | | {
8 | status: "init";
9 | }
10 | | {
11 | status: "invoking";
12 | }
13 | | {
14 | renderId: string;
15 | bucketName: string;
16 | progress: number;
17 | status: "rendering";
18 | }
19 | | {
20 | renderId: string | null;
21 | status: "error";
22 | error: Error;
23 | }
24 | | {
25 | url: string;
26 | size: number;
27 | status: "done";
28 | };
29 |
30 | const wait = async (milliSeconds: number) => {
31 | await new Promise((resolve) => {
32 | setTimeout(() => {
33 | resolve();
34 | }, milliSeconds);
35 | });
36 | };
37 |
38 | export const useRendering = (
39 | id: string,
40 | inputProps: z.infer
41 | ) => {
42 | const [state, setState] = useState({
43 | status: "init",
44 | });
45 |
46 | const renderMedia = useCallback(async () => {
47 | setState({
48 | status: "invoking",
49 | });
50 | try {
51 | const { renderId, bucketName } = await renderVideo({ id, inputProps });
52 | setState({
53 | status: "rendering",
54 | progress: 0,
55 | renderId: renderId,
56 | bucketName: bucketName,
57 | });
58 |
59 | let pending = true;
60 |
61 | while (pending) {
62 | const result = await getProgress({
63 | id: renderId,
64 | bucketName: bucketName,
65 | });
66 | switch (result.type) {
67 | case "error": {
68 | setState({
69 | status: "error",
70 | renderId: renderId,
71 | error: new Error(result.message),
72 | });
73 | pending = false;
74 | break;
75 | }
76 | case "done": {
77 | setState({
78 | size: result.size,
79 | url: result.url,
80 | status: "done",
81 | });
82 | pending = false;
83 | break;
84 | }
85 | case "progress": {
86 | setState({
87 | status: "rendering",
88 | bucketName: bucketName,
89 | progress: result.progress,
90 | renderId: renderId,
91 | });
92 | await wait(1000);
93 | }
94 | }
95 | }
96 | } catch (err) {
97 | setState({
98 | status: "error",
99 | error: err as Error,
100 | renderId: null,
101 | });
102 | }
103 | }, [id, inputProps]);
104 |
105 | const undo = useCallback(() => {
106 | setState({ status: "init" });
107 | }, []);
108 |
109 | return useMemo(() => {
110 | return {
111 | renderMedia,
112 | state,
113 | undo,
114 | };
115 | }, [renderMedia, state, undo]);
116 | };
117 |
--------------------------------------------------------------------------------
/lambda/api.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import type { RenderMediaOnLambdaOutput } from "@remotion/lambda/client";
3 | import {
4 | ProgressRequest,
5 | ProgressResponse,
6 | RenderRequest,
7 | } from "../types/schema";
8 | import { CompositionProps } from "../types/constants";
9 | import { ApiResponse } from "../helpers/api-response";
10 |
11 | const makeRequest = async (
12 | endpoint: string,
13 | body: unknown
14 | ): Promise => {
15 | const result = await fetch(endpoint, {
16 | method: "post",
17 | body: JSON.stringify(body),
18 | headers: {
19 | "content-type": "application/json",
20 | },
21 | });
22 | const json = (await result.json()) as ApiResponse;
23 | if (json.type === "error") {
24 | throw new Error(json.message);
25 | }
26 |
27 | return json.data;
28 | };
29 |
30 | export const renderVideo = async ({
31 | id,
32 | inputProps,
33 | }: {
34 | id: string;
35 | inputProps: z.infer;
36 | }) => {
37 | const body: z.infer = {
38 | id,
39 | inputProps,
40 | };
41 |
42 | return makeRequest("/api/lambda/render", body);
43 | };
44 |
45 | export const getProgress = async ({
46 | id,
47 | bucketName,
48 | }: {
49 | id: string;
50 | bucketName: string;
51 | }) => {
52 | const body: z.infer = {
53 | id,
54 | bucketName,
55 | };
56 |
57 | return makeRequest("/api/lambda/progress", body);
58 | };
59 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | }
6 |
7 | module.exports = nextConfig
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "contribution-graph",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "eslint .",
10 | "remotion": "remotion studio",
11 | "render": "remotion render",
12 | "deploy": "node deploy.mjs"
13 | },
14 | "dependencies": {
15 | "@remotion/cli": "4.0.51",
16 | "@remotion/google-fonts": "4.0.51",
17 | "@remotion/bundler": "4.0.51",
18 | "@remotion/lambda": "4.0.51",
19 | "@remotion/paths": "4.0.51",
20 | "@remotion/player": "4.0.51",
21 | "@remotion/shapes": "4.0.51",
22 | "next": "13.4.13",
23 | "react": "18.2.0",
24 | "react-dom": "18.2.0",
25 | "remotion": "4.0.51",
26 | "zod": "^3.21.4"
27 | },
28 | "devDependencies": {
29 | "dotenv": "^16.0.3",
30 | "@remotion/eslint-config": "4.0.51",
31 | "@types/node": "18.7.23",
32 | "@types/react": "18.0.26",
33 | "@types/react-dom": "18.0.6",
34 | "@types/web": "^0.0.61",
35 | "eslint": "^8.43.0",
36 | "eslint-config-next": "^13.4.13",
37 | "typescript": "4.8.3",
38 | "prettier": "^2.8.8"
39 | },
40 | "packageManager": "npm@10.1.0"
41 | }
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '../styles/global.css'
2 | import type { AppProps } from 'next/app'
3 |
4 | function MyApp({ Component, pageProps }: AppProps) {
5 | return
6 | }
7 |
8 | export default MyApp
9 |
--------------------------------------------------------------------------------
/pages/api/github.ts:
--------------------------------------------------------------------------------
1 | import { IncomingMessage, ServerResponse } from "http";
2 |
3 | const query = (username: string) => {
4 | return `
5 | query {
6 | user(login: "${username}") {
7 | contributionsCollection {
8 | contributionCalendar {
9 | totalContributions
10 | weeks {
11 | contributionDays {
12 | contributionCount
13 | date
14 | }
15 | }
16 | }
17 |
18 | }
19 | }
20 | }
21 | `;
22 | };
23 |
24 | async function handler(req: IncomingMessage, res: ServerResponse) {
25 | if (req.method !== "GET") {
26 | throw new Error("Only GET requests are allowed");
27 | }
28 |
29 | // @ts-expect-error
30 | const username = req.query.username as string;
31 |
32 | if (typeof username !== "string") {
33 | throw new Error("username is required");
34 | }
35 |
36 | const data = await fetch(`https://api.github.com/graphql`, {
37 | method: "post",
38 | body: JSON.stringify({ query: query(username) }),
39 | headers: {
40 | Authorization: `Bearer ${process.env.REMOTION_GITHUB_TOKEN}`,
41 | "content-type": "application/json",
42 | },
43 | });
44 | const json = await data.json();
45 | res.statusCode = 200;
46 | res.setHeader("Content-Type", "application/json");
47 | // Implement CORS
48 | res.setHeader("Access-Control-Allow-Origin", "*");
49 | res.setHeader(
50 | "Access-Control-Allow-Methods",
51 | "GET,OPTIONS,PATCH,DELETE,POST,PUT"
52 | );
53 | res.setHeader("Access-Control-Allow-Headers", "Content-Type");
54 |
55 | res.end(JSON.stringify(json));
56 | }
57 |
58 | export default handler;
59 |
--------------------------------------------------------------------------------
/pages/api/lambda/progress.ts:
--------------------------------------------------------------------------------
1 | import {
2 | speculateFunctionName,
3 | AwsRegion,
4 | getRenderProgress,
5 | } from "@remotion/lambda/client";
6 | import { DISK, RAM, REGION, TIMEOUT } from "../../../config.mjs";
7 | import { executeApi } from "../../../helpers/api-response";
8 | import { ProgressRequest, ProgressResponse } from "../../../types/schema";
9 |
10 | const progress = executeApi(
11 | ProgressRequest,
12 | async (req, body) => {
13 | if (req.method !== "POST") {
14 | throw new Error("Only POST requests are allowed");
15 | }
16 |
17 | const renderProgress = await getRenderProgress({
18 | bucketName: body.bucketName,
19 | functionName: speculateFunctionName({
20 | diskSizeInMb: DISK,
21 | memorySizeInMb: RAM,
22 | timeoutInSeconds: TIMEOUT,
23 | }),
24 | region: REGION as AwsRegion,
25 | renderId: body.id,
26 | });
27 |
28 | if (renderProgress.fatalErrorEncountered) {
29 | return {
30 | type: "error",
31 | message: renderProgress.errors[0].message,
32 | };
33 | }
34 |
35 | if (renderProgress.done) {
36 | return {
37 | type: "done",
38 | url: renderProgress.outputFile as string,
39 | size: renderProgress.outputSizeInBytes as number,
40 | };
41 | }
42 |
43 | return {
44 | type: "progress",
45 | progress: Math.max(0.03, renderProgress.overallProgress),
46 | };
47 | }
48 | );
49 |
50 | export default progress;
51 |
--------------------------------------------------------------------------------
/pages/api/lambda/render.ts:
--------------------------------------------------------------------------------
1 | import { AwsRegion, RenderMediaOnLambdaOutput } from "@remotion/lambda/client";
2 | import {
3 | renderMediaOnLambda,
4 | speculateFunctionName,
5 | } from "@remotion/lambda/client";
6 | import { DISK, RAM, REGION, SITE_NAME, TIMEOUT } from "../../../config.mjs";
7 | import { executeApi } from "../../../helpers/api-response";
8 | import { RenderRequest } from "../../../types/schema";
9 |
10 | const render = executeApi(
11 | RenderRequest,
12 | async (req, body) => {
13 | if (req.method !== "POST") {
14 | throw new Error("Only POST requests are allowed");
15 | }
16 |
17 | if (
18 | !process.env.AWS_ACCESS_KEY_ID &&
19 | !process.env.REMOTION_AWS_ACCESS_KEY_ID
20 | ) {
21 | throw new TypeError(
22 | "Set up Remotion Lambda to render videos. See the README.md for how to do so."
23 | );
24 | }
25 | if (
26 | !process.env.AWS_SECRET_ACCESS_KEY &&
27 | !process.env.REMOTION_AWS_SECRET_ACCESS_KEY
28 | ) {
29 | throw new TypeError(
30 | "The environment variable REMOTION_AWS_SECRET_ACCESS_KEY is missing. Add it to your .env file."
31 | );
32 | }
33 |
34 | const result = await renderMediaOnLambda({
35 | codec: "h264",
36 | functionName: speculateFunctionName({
37 | diskSizeInMb: DISK,
38 | memorySizeInMb: RAM,
39 | timeoutInSeconds: TIMEOUT,
40 | }),
41 | region: REGION as AwsRegion,
42 | serveUrl: SITE_NAME,
43 | composition: body.id,
44 | inputProps: body.inputProps,
45 | framesPerLambda: 10,
46 | downloadBehavior: {
47 | type: "download",
48 | fileName: "video.mp4",
49 | },
50 | });
51 |
52 | return result;
53 | }
54 | );
55 |
56 | export default render;
57 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Player } from "@remotion/player";
2 | import type { NextPage } from "next";
3 | import Head from "next/head";
4 | import React, { useMemo, useState } from "react";
5 | import { Main } from "../remotion/MyComp/Main";
6 | import {
7 | CompositionProps,
8 | defaultMyCompProps,
9 | DURATION_IN_FRAMES,
10 | VIDEO_FPS,
11 | VIDEO_HEIGHT,
12 | VIDEO_WIDTH,
13 | } from "../types/constants";
14 | import { z } from "zod";
15 | import { RenderControls } from "../components/RenderControls";
16 | import { Spacing } from "../components/Spacing";
17 |
18 | const container: React.CSSProperties = {
19 | maxWidth: 768,
20 | margin: "auto",
21 | marginBottom: 20,
22 | };
23 |
24 | const outer: React.CSSProperties = {
25 | borderRadius: "var(--geist-border-radius)",
26 | overflow: "hidden",
27 | boxShadow: "0 0 200px rgba(0, 0, 0, 0.15)",
28 | marginBottom: 40,
29 | marginTop: 60,
30 | };
31 |
32 | const player: React.CSSProperties = {
33 | width: "100%",
34 | };
35 |
36 | const Home: NextPage = () => {
37 | const [text, setText] = useState(defaultMyCompProps.username);
38 |
39 | const inputProps: z.infer = useMemo(() => {
40 | return {
41 | username: text,
42 | };
43 | }, [text]);
44 |
45 | return (
46 |
47 |
48 |
Remotion and Next.js
49 |
50 |
54 |
55 |
56 |
57 |
71 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | );
83 | };
84 |
85 | export default Home;
86 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JonnyBurger/contribution-graph/8ac79bea6c9305cf770cb18bd6a12d0cafc0e652/public/favicon.ico
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/remotion.config.ts:
--------------------------------------------------------------------------------
1 | // See all configuration options: https://remotion.dev/docs/config
2 | // Each option also is available as a CLI flag: https://remotion.dev/docs/cli
3 |
4 | // Note: When using the Node.JS APIs, the config file doesn't apply. Instead, pass options directly to the APIs
5 |
6 | import { Config } from "@remotion/cli/config";
7 |
8 | Config.setVideoImageFormat("jpeg");
9 |
--------------------------------------------------------------------------------
/remotion/MyComp/Main.tsx:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import {
3 | AbsoluteFill,
4 | continueRender,
5 | delayRender,
6 | interpolate,
7 | useCurrentFrame,
8 | } from "remotion";
9 | import { CompositionProps } from "../../types/constants";
10 | import { loadFont, fontFamily } from "@remotion/google-fonts/Inter";
11 | import React, { useCallback, useEffect, useState } from "react";
12 | import { GitHubResponse } from "./types";
13 | import { Tile } from "./Tile";
14 |
15 | const dayDuration = 0.2;
16 |
17 | loadFont();
18 |
19 | const container: React.CSSProperties = {
20 | backgroundColor: "white",
21 | display: "flex",
22 | flexDirection: "column",
23 | flex: 1,
24 | justifyContent: "center",
25 | alignItems: "center",
26 | };
27 |
28 | export const Main = ({ username }: z.infer) => {
29 | const frame = useCurrentFrame();
30 | const [data, setData] = useState(null);
31 | const [handle] = useState(() => delayRender());
32 |
33 | const fetchData = useCallback(
34 | async (name: string) => {
35 | const res = await fetch(
36 | `https://contribution-graph-red.vercel.app/api/github?username=${name}`
37 | );
38 | const json = await res.json();
39 | setData(json);
40 | continueRender(handle);
41 | },
42 | [handle]
43 | );
44 |
45 | useEffect(() => {
46 | fetchData(username);
47 | }, [fetchData, username]);
48 |
49 | if (!data || !data.data.user) {
50 | return null;
51 | }
52 |
53 | const flatContributions =
54 | data.data.user.contributionsCollection.contributionCalendar.weeks
55 | .map((w) => w.contributionDays.map((d) => d.contributionCount))
56 | .flat(1);
57 |
58 | const highestAmountOfContributionsInADay = Math.max(...flatContributions);
59 |
60 | const usernameOpacity = interpolate(frame, [0, 30], [0, 1], {
61 | extrapolateRight: "clamp",
62 | });
63 |
64 | const contributionsOpacity = interpolate(frame, [30, 60], [0, 1], {
65 | extrapolateRight: "clamp",
66 | extrapolateLeft: "clamp",
67 | });
68 |
69 | return (
70 |
71 |
79 | {username}
80 |
81 |
82 | {flatContributions.reduce((a, b) => a + b, 0)} contributions
83 |
84 |
85 |
86 | {data.data.user.contributionsCollection.contributionCalendar.weeks.map(
87 | (w, week) => {
88 | return (
89 |
98 | {w.contributionDays.map((day, i) => {
99 | return (
100 |
108 | );
109 | })}
110 |
111 | );
112 | }
113 | )}
114 |
115 |
116 | );
117 | };
118 |
--------------------------------------------------------------------------------
/remotion/MyComp/Tile.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | interpolateColors,
4 | spring,
5 | useCurrentFrame,
6 | useVideoConfig,
7 | } from "remotion";
8 |
9 | const SUPERGREEN = "#386C3E";
10 | const SLIGHLTY_GREEN = "#ACE7AE";
11 | const GRAY = "#EBEDF0";
12 |
13 | export const Tile: React.FC<{
14 | amountOfGreen: number;
15 | delay: number;
16 | }> = ({ amountOfGreen, delay }) => {
17 | const frame = useCurrentFrame();
18 | const { fps } = useVideoConfig();
19 |
20 | const spr = spring({
21 | frame,
22 | fps,
23 | delay,
24 | config: {
25 | mass: 2.5,
26 | },
27 | durationInFrames: 30,
28 | });
29 |
30 | return (
31 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/remotion/MyComp/types.ts:
--------------------------------------------------------------------------------
1 | export type GitHubResponse = {
2 | data: {
3 | user: {
4 | contributionsCollection: {
5 | contributionCalendar: {
6 | weeks: Week[];
7 | };
8 | };
9 | };
10 | };
11 | };
12 |
13 | type Week = {
14 | contributionDays: ContributionDay[];
15 | };
16 |
17 | type ContributionDay = {
18 | contributionCount: number;
19 | date: string;
20 | };
21 |
--------------------------------------------------------------------------------
/remotion/Root.tsx:
--------------------------------------------------------------------------------
1 | import { Composition } from "remotion";
2 | import { Main } from "./MyComp/Main";
3 | import {
4 | CompositionProps,
5 | COMP_NAME,
6 | defaultMyCompProps,
7 | DURATION_IN_FRAMES,
8 | VIDEO_FPS,
9 | VIDEO_HEIGHT,
10 | VIDEO_WIDTH,
11 | } from "../types/constants";
12 |
13 | export const RemotionRoot: React.FC = () => {
14 | return (
15 | <>
16 |
26 | >
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/remotion/index.ts:
--------------------------------------------------------------------------------
1 | import { registerRoot } from "remotion";
2 | import { RemotionRoot } from "./Root";
3 |
4 | registerRoot(RemotionRoot);
5 |
--------------------------------------------------------------------------------
/styles/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --background: #fff;
3 | --foreground: #000;
4 | --unfocused-border-color: #eaeaea;
5 | --focused-border-color: #666;
6 |
7 | --button-disabled-color: #fafafa;
8 | --disabled-text-color: #999;
9 |
10 | --geist-border-radius: 5px;
11 | --geist-quarter-pad: 6px;
12 | --geist-half-pad: 12px;
13 | --geist-pad: 24px;
14 | --geist-font: "Inter";
15 |
16 | --geist-error: #e00;
17 |
18 | --subtitle: #666;
19 | }
20 |
21 | * {
22 | box-sizing: border-box;
23 | }
24 |
25 | .cinematics {
26 | box-shadow: 0 0 200px rgba(0, 0, 0, 0.15);
27 | }
28 |
29 | @media (prefers-color-scheme: dark) {
30 | :root {
31 | --background: #000000;
32 | --unfocused-border-color: #333;
33 | --focused-border-color: #888;
34 | --foreground: #fff;
35 | --button-disabled-color: #111;
36 | --geist-error: red;
37 | --subtitle: #8D8D8D
38 |
39 | }
40 | .cinematics {
41 | box-shadow: 0 0 200px rgba(255, 255, 255, 0.15);
42 | }
43 | }
44 |
45 | body {
46 | background-color: var(--background);
47 | }
48 |
49 |
50 | input {
51 | border: 1px solid var(--unfocused-border-color);
52 | transition: border-color 0.15s ease;
53 | outline: none;
54 | }
55 |
56 | input:focus {
57 | border-color: var(--focused-border-color);
58 | }
59 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "noUnusedLocals": true
18 | },
19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/types/constants.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | export const COMP_NAME = "MyComp";
3 |
4 | export const CompositionProps = z.object({
5 | username: z.string(),
6 | });
7 |
8 | export const defaultMyCompProps: z.infer = {
9 | username: "JonnyBurger",
10 | };
11 |
12 | export const DURATION_IN_FRAMES = 200;
13 | export const VIDEO_WIDTH = 1280;
14 | export const VIDEO_HEIGHT = 720;
15 | export const VIDEO_FPS = 30;
16 |
--------------------------------------------------------------------------------
/types/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { CompositionProps } from "./constants";
3 |
4 | export const RenderRequest = z.object({
5 | id: z.string(),
6 | inputProps: CompositionProps,
7 | });
8 |
9 | export const ProgressRequest = z.object({
10 | bucketName: z.string(),
11 | id: z.string(),
12 | });
13 |
14 | export type ProgressResponse =
15 | | {
16 | type: "error";
17 | message: string;
18 | }
19 | | {
20 | type: "progress";
21 | progress: number;
22 | }
23 | | {
24 | type: "done";
25 | url: string;
26 | size: number;
27 | };
28 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "node deploy.mjs && next build"
3 | }
4 |
--------------------------------------------------------------------------------