├── .gitignore
├── .prettierrc
├── README.md
├── eslint.config.mjs
├── package.json
├── public
├── sample-video.mp4
├── theboldfont-license.rtf
└── theboldfont.ttf
├── remotion.config.ts
├── src
├── CaptionedVideo
│ ├── NoCaptionFile.tsx
│ ├── Page.tsx
│ ├── SubtitlePage.tsx
│ └── index.tsx
├── Root.tsx
├── index.ts
└── load-font.ts
├── sub.mjs
├── tsconfig.json
└── whisper-config.mjs
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .DS_Store
4 | .env
5 |
6 | # Ignore the output video from Git but not videos you import into src/.
7 | out
8 | whisper.cpp
9 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": false,
3 | "bracketSpacing": true,
4 | "tabWidth": 2
5 | }
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Remotion video
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Welcome to your Remotion project!
13 |
14 | ## Commands
15 |
16 | **Install Dependencies**
17 |
18 | ```console
19 | npm i
20 | ```
21 |
22 | **Start Preview**
23 |
24 | ```console
25 | npm run dev
26 | ```
27 |
28 | **Render video**
29 |
30 | ```console
31 | npx remotion render
32 | ```
33 |
34 | **Upgrade Remotion**
35 |
36 | ```console
37 | npx remotion upgrade
38 | ```
39 |
40 | ## Captioning
41 |
42 | Replace the `sample-video.mp4` with your video file.
43 | Caption all the videos in you `public` by running the following command:
44 |
45 | ```console
46 | node sub.mjs
47 | ```
48 |
49 | Only caption a specific video:
50 |
51 | ```console
52 | node sub.mjs
53 | ```
54 |
55 | Only caption a specific folder:
56 |
57 | ```console
58 | node sub.mjs
59 | ```
60 |
61 | ## Configure Whisper.cpp
62 |
63 | Captioning will download Whisper.cpp and the 1.5GB big `medium.en` model. To configure which model is being used, you can configure the variables in `whisper-config.mjs`.
64 |
65 | ### Non-English languages
66 |
67 | To support non-English languages, you need to change the `WHISPER_MODEL` variable in `whisper-config.mjs` to a model that does not have a `.en` sufix.
68 |
69 | ## Docs
70 |
71 | Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals).
72 |
73 | ## Help
74 |
75 | We provide help on our [Discord server](https://remotion.dev/discord).
76 |
77 | ## Issues
78 |
79 | Found an issue with Remotion? [File an issue here](https://github.com/remotion-dev/remotion/issues/new).
80 |
81 | ## License
82 |
83 | Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md).
84 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { config } from "@remotion/eslint-config-flat";
2 |
3 | export default config;
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "template-tiktok",
3 | "version": "1.0.0",
4 | "description": "A Remotion template for TikTok-style captions",
5 | "scripts": {
6 | "dev": "remotion studio",
7 | "build": "remotion bundle",
8 | "upgrade": "remotion upgrade",
9 | "lint": "eslint src && tsc",
10 | "create-subtitles": "node sub.mjs"
11 | },
12 | "repository": {},
13 | "license": "UNLICENSED",
14 | "dependencies": {
15 | "@remotion/cli": "^4.0.0",
16 | "@remotion/zod-types": "^4.0.0",
17 | "@remotion/animation-utils": "^4.0.0",
18 | "@remotion/layout-utils": "^4.0.0",
19 | "@remotion/media-utils": "^4.0.0",
20 | "react": "19.0.0",
21 | "react-dom": "19.0.0",
22 | "remotion": "^4.0.0",
23 | "zod": "3.22.3"
24 | },
25 | "devDependencies": {
26 | "@remotion/eslint-config-flat": "^4.0.0",
27 | "@remotion/install-whisper-cpp": "^4.0.0",
28 | "@remotion/captions": "^4.0.0",
29 | "@types/react": "19.0.0",
30 | "@types/web": "0.0.166",
31 | "eslint": "9.19.0",
32 | "prettier": "3.6.0",
33 | "typescript": "5.8.2"
34 | },
35 | "private": true
36 | }
37 |
--------------------------------------------------------------------------------
/public/sample-video.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remotion-dev/template-tiktok/ba16e249c44d4dfbf9f58d16d278855f658fc55e/public/sample-video.mp4
--------------------------------------------------------------------------------
/public/theboldfont-license.rtf:
--------------------------------------------------------------------------------
1 | {\rtf1\ansi\ansicpg1252\cocoartf1504\cocoasubrtf840
2 | {\fonttbl\f0\fswiss\fcharset0 Helvetica;}
3 | {\colortbl;\red255\green255\blue255;\red0\green0\blue0;\red255\green255\blue255;}
4 | {\*\expandedcolortbl;;\csgenericrgb\c0\c0\c0;\cssrgb\c100000\c100000\c100000;}
5 | \paperw11900\paperh16840\margl1440\margr1440\vieww10800\viewh8400\viewkind0
6 | \pard\tx566\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\pardirnatural\partightenfactor0
7 |
8 | \f0\fs24 \cf0 \
9 | Thank you for downloading \
10 |
11 | \b\fs28 THE BOLD FONT.
12 | \b0\fs24 \
13 | \
14 | It is a 100% free font so you can use it as much as you like for whatever you like.\
15 | Of course every donation for my effort would be highly appreciated. You can find a donate button on the DaFont page where you downloaded the font.\
16 | \
17 | Did you know that there is also a
18 | \b PRO
19 | \b0 version available. It comes with lowercase characters and has way more glyphs that the free version. You can get it at:\
20 | \
21 | {\field{\*\fldinst{HYPERLINK "https://the-bold-font.com/"}}{\fldrslt
22 | \b\fs28 WWW.THE-BOLD-FONT.COM }}
23 | \b \
24 |
25 | \b0 \
26 | Thank you so much and have fun designing with
27 | \b\fs28 THE BOLD FONT
28 | \b0\fs24 !\
29 | \
30 | \
31 | \pard\pardeftab720\sl380\partightenfactor0
32 | \cf2 \cb3 \expnd0\expndtw0\kerning0
33 | Yours sincerely,\
34 | Sven Pels\
35 |
36 | \b\fs28 THE BOLD FONT.\
37 |
38 | \b0\fs24 \
39 | Copyright \'a9 2023
40 | \b THE BOLD FONT.
41 | \b0 Sven Pels. All rights reserved.}
--------------------------------------------------------------------------------
/public/theboldfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/remotion-dev/template-tiktok/ba16e249c44d4dfbf9f58d16d278855f658fc55e/public/theboldfont.ttf
--------------------------------------------------------------------------------
/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 | Config.setOverwriteOutput(true);
10 |
--------------------------------------------------------------------------------
/src/CaptionedVideo/NoCaptionFile.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { AbsoluteFill } from "remotion";
3 |
4 | export const NoCaptionFile: React.FC = () => {
5 | return (
6 |
17 | No caption file found in the public folder.
Run `node sub.mjs` to
18 | install Whisper.cpp and create one.
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/CaptionedVideo/Page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | AbsoluteFill,
4 | interpolate,
5 | useCurrentFrame,
6 | useVideoConfig,
7 | } from "remotion";
8 | import { TheBoldFont } from "../load-font";
9 | import { fitText } from "@remotion/layout-utils";
10 | import { makeTransform, scale, translateY } from "@remotion/animation-utils";
11 | import { TikTokPage } from "@remotion/captions";
12 |
13 | const fontFamily = TheBoldFont;
14 |
15 | const container: React.CSSProperties = {
16 | justifyContent: "center",
17 | alignItems: "center",
18 | top: undefined,
19 | bottom: 350,
20 | height: 150,
21 | };
22 |
23 | const DESIRED_FONT_SIZE = 120;
24 | const HIGHLIGHT_COLOR = "#39E508";
25 |
26 | export const Page: React.FC<{
27 | readonly enterProgress: number;
28 | readonly page: TikTokPage;
29 | }> = ({ enterProgress, page }) => {
30 | const frame = useCurrentFrame();
31 | const { width, fps } = useVideoConfig();
32 | const timeInMs = (frame / fps) * 1000;
33 |
34 | const fittedText = fitText({
35 | fontFamily,
36 | text: page.text,
37 | withinWidth: width * 0.9,
38 | textTransform: "uppercase",
39 | });
40 |
41 | const fontSize = Math.min(DESIRED_FONT_SIZE, fittedText.fontSize);
42 |
43 | return (
44 |
45 |
59 |
67 | {page.tokens.map((t) => {
68 | const startRelativeToSequence = t.fromMs - page.startMs;
69 | const endRelativeToSequence = t.toMs - page.startMs;
70 |
71 | const active =
72 | startRelativeToSequence <= timeInMs &&
73 | endRelativeToSequence > timeInMs;
74 |
75 | return (
76 |
84 | {t.text}
85 |
86 | );
87 | })}
88 |
89 |
90 |
91 | );
92 | };
93 |
--------------------------------------------------------------------------------
/src/CaptionedVideo/SubtitlePage.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | AbsoluteFill,
4 | spring,
5 | useCurrentFrame,
6 | useVideoConfig,
7 | } from "remotion";
8 | import { Page } from "./Page";
9 | import { TikTokPage } from "@remotion/captions";
10 |
11 | const SubtitlePage: React.FC<{ readonly page: TikTokPage }> = ({ page }) => {
12 | const frame = useCurrentFrame();
13 | const { fps } = useVideoConfig();
14 |
15 | const enter = spring({
16 | frame,
17 | fps,
18 | config: {
19 | damping: 200,
20 | },
21 | durationInFrames: 5,
22 | });
23 |
24 | return (
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default SubtitlePage;
32 |
--------------------------------------------------------------------------------
/src/CaptionedVideo/index.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo, useState } from "react";
2 | import {
3 | AbsoluteFill,
4 | CalculateMetadataFunction,
5 | cancelRender,
6 | continueRender,
7 | delayRender,
8 | getStaticFiles,
9 | OffthreadVideo,
10 | Sequence,
11 | useVideoConfig,
12 | watchStaticFile,
13 | } from "remotion";
14 | import { z } from "zod";
15 | import SubtitlePage from "./SubtitlePage";
16 | import { getVideoMetadata } from "@remotion/media-utils";
17 | import { loadFont } from "../load-font";
18 | import { NoCaptionFile } from "./NoCaptionFile";
19 | import { Caption, createTikTokStyleCaptions } from "@remotion/captions";
20 |
21 | export type SubtitleProp = {
22 | startInSeconds: number;
23 | text: string;
24 | };
25 |
26 | export const captionedVideoSchema = z.object({
27 | src: z.string(),
28 | });
29 |
30 | export const calculateCaptionedVideoMetadata: CalculateMetadataFunction<
31 | z.infer
32 | > = async ({ props }) => {
33 | const fps = 30;
34 | const metadata = await getVideoMetadata(props.src);
35 |
36 | return {
37 | fps,
38 | durationInFrames: Math.floor(metadata.durationInSeconds * fps),
39 | };
40 | };
41 |
42 | const getFileExists = (file: string) => {
43 | const files = getStaticFiles();
44 | const fileExists = files.find((f) => {
45 | return f.src === file;
46 | });
47 | return Boolean(fileExists);
48 | };
49 |
50 | // How many captions should be displayed at a time?
51 | // Try out:
52 | // - 1500 to display a lot of words at a time
53 | // - 200 to only display 1 word at a time
54 | const SWITCH_CAPTIONS_EVERY_MS = 1200;
55 |
56 | export const CaptionedVideo: React.FC<{
57 | src: string;
58 | }> = ({ src }) => {
59 | const [subtitles, setSubtitles] = useState([]);
60 | const [handle] = useState(() => delayRender());
61 | const { fps } = useVideoConfig();
62 |
63 | const subtitlesFile = src
64 | .replace(/.mp4$/, ".json")
65 | .replace(/.mkv$/, ".json")
66 | .replace(/.mov$/, ".json")
67 | .replace(/.webm$/, ".json");
68 |
69 | const fetchSubtitles = useCallback(async () => {
70 | try {
71 | await loadFont();
72 | const res = await fetch(subtitlesFile);
73 | const data = (await res.json()) as Caption[];
74 | setSubtitles(data);
75 | continueRender(handle);
76 | } catch (e) {
77 | cancelRender(e);
78 | }
79 | }, [handle, subtitlesFile]);
80 |
81 | useEffect(() => {
82 | fetchSubtitles();
83 |
84 | const c = watchStaticFile(subtitlesFile, () => {
85 | fetchSubtitles();
86 | });
87 |
88 | return () => {
89 | c.cancel();
90 | };
91 | }, [fetchSubtitles, src, subtitlesFile]);
92 |
93 | const { pages } = useMemo(() => {
94 | return createTikTokStyleCaptions({
95 | combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
96 | captions: subtitles ?? [],
97 | });
98 | }, [subtitles]);
99 |
100 | return (
101 |
102 |
103 |
109 |
110 | {pages.map((page, index) => {
111 | const nextPage = pages[index + 1] ?? null;
112 | const subtitleStartFrame = (page.startMs / 1000) * fps;
113 | const subtitleEndFrame = Math.min(
114 | nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
115 | subtitleStartFrame + SWITCH_CAPTIONS_EVERY_MS,
116 | );
117 | const durationInFrames = subtitleEndFrame - subtitleStartFrame;
118 | if (durationInFrames <= 0) {
119 | return null;
120 | }
121 |
122 | return (
123 |
128 | ;
129 |
130 | );
131 | })}
132 | {getFileExists(subtitlesFile) ? null : }
133 |
134 | );
135 | };
136 |
--------------------------------------------------------------------------------
/src/Root.tsx:
--------------------------------------------------------------------------------
1 | import { Composition, staticFile } from "remotion";
2 | import {
3 | CaptionedVideo,
4 | calculateCaptionedVideoMetadata,
5 | captionedVideoSchema,
6 | } from "./CaptionedVideo";
7 |
8 | // Each is an entry in the sidebar!
9 |
10 | export const RemotionRoot: React.FC = () => {
11 | return (
12 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // This is your entry file! Refer to it when you render:
2 | // npx remotion render HelloWorld out/video.mp4
3 |
4 | import { registerRoot } from "remotion";
5 | import { RemotionRoot } from "./Root";
6 |
7 | registerRoot(RemotionRoot);
8 |
--------------------------------------------------------------------------------
/src/load-font.ts:
--------------------------------------------------------------------------------
1 | import { continueRender, delayRender, staticFile } from "remotion";
2 |
3 | export const TheBoldFont = `TheBoldFont`;
4 |
5 | let loaded = false;
6 |
7 | export const loadFont = async (): Promise => {
8 | if (loaded) {
9 | return Promise.resolve();
10 | }
11 |
12 | const waitForFont = delayRender();
13 |
14 | loaded = true;
15 |
16 | const font = new FontFace(
17 | TheBoldFont,
18 | `url('${staticFile("theboldfont.ttf")}') format('truetype')`,
19 | );
20 |
21 | await font.load();
22 | document.fonts.add(font);
23 |
24 | continueRender(waitForFont);
25 | };
26 |
--------------------------------------------------------------------------------
/sub.mjs:
--------------------------------------------------------------------------------
1 | import { execSync } from "node:child_process";
2 | import {
3 | existsSync,
4 | rmSync,
5 | writeFileSync,
6 | lstatSync,
7 | mkdirSync,
8 | readdirSync,
9 | } from "node:fs";
10 | import path from "path";
11 | import {
12 | WHISPER_LANG,
13 | WHISPER_MODEL,
14 | WHISPER_PATH,
15 | WHISPER_VERSION,
16 | } from "./whisper-config.mjs";
17 | import {
18 | downloadWhisperModel,
19 | installWhisperCpp,
20 | transcribe,
21 | toCaptions,
22 | } from "@remotion/install-whisper-cpp";
23 |
24 | const extractToTempAudioFile = (fileToTranscribe, tempOutFile) => {
25 | // Extracting audio from mp4 and save it as 16khz wav file
26 | execSync(
27 | `npx remotion ffmpeg -i "${fileToTranscribe}" -ar 16000 "${tempOutFile}" -y`,
28 | { stdio: ["ignore", "inherit"] },
29 | );
30 | };
31 |
32 | const subFile = async (filePath, fileName, folder) => {
33 | const outPath = path.join(
34 | process.cwd(),
35 | "public",
36 | folder,
37 | fileName.replace(".wav", ".json"),
38 | );
39 |
40 | const whisperCppOutput = await transcribe({
41 | inputPath: filePath,
42 | model: WHISPER_MODEL,
43 | tokenLevelTimestamps: true,
44 | whisperPath: WHISPER_PATH,
45 | whisperCppVersion: WHISPER_VERSION,
46 | printOutput: false,
47 | translateToEnglish: false,
48 | language: WHISPER_LANG,
49 | splitOnWord: true,
50 | });
51 |
52 | const { captions } = toCaptions({
53 | whisperCppOutput,
54 | });
55 | writeFileSync(
56 | outPath.replace("webcam", "subs"),
57 | JSON.stringify(captions, null, 2),
58 | );
59 | };
60 |
61 | const processVideo = async (fullPath, entry, directory) => {
62 | if (
63 | !fullPath.endsWith(".mp4") &&
64 | !fullPath.endsWith(".webm") &&
65 | !fullPath.endsWith(".mkv") &&
66 | !fullPath.endsWith(".mov")
67 | ) {
68 | return;
69 | }
70 |
71 | const isTranscribed = existsSync(
72 | fullPath
73 | .replace(/.mp4$/, ".json")
74 | .replace(/.mkv$/, ".json")
75 | .replace(/.mov$/, ".json")
76 | .replace(/.webm$/, ".json")
77 | .replace("webcam", "subs"),
78 | );
79 | if (isTranscribed) {
80 | return;
81 | }
82 | let shouldRemoveTempDirectory = false;
83 | if (!existsSync(path.join(process.cwd(), "temp"))) {
84 | mkdirSync(`temp`);
85 | shouldRemoveTempDirectory = true;
86 | }
87 | console.log("Extracting audio from file", entry);
88 |
89 | const tempWavFileName = entry.split(".")[0] + ".wav";
90 | const tempOutFilePath = path.join(process.cwd(), `temp/${tempWavFileName}`);
91 |
92 | extractToTempAudioFile(fullPath, tempOutFilePath);
93 | await subFile(
94 | tempOutFilePath,
95 | tempWavFileName,
96 | path.relative("public", directory),
97 | );
98 | if (shouldRemoveTempDirectory) {
99 | rmSync(path.join(process.cwd(), "temp"), { recursive: true });
100 | }
101 | };
102 |
103 | const processDirectory = async (directory) => {
104 | const entries = readdirSync(directory).filter((f) => f !== ".DS_Store");
105 |
106 | for (const entry of entries) {
107 | const fullPath = path.join(directory, entry);
108 | const stat = lstatSync(fullPath);
109 |
110 | if (stat.isDirectory()) {
111 | await processDirectory(fullPath); // Recurse into subdirectories
112 | } else {
113 | await processVideo(fullPath, entry, directory);
114 | }
115 | }
116 | };
117 |
118 | await installWhisperCpp({ to: WHISPER_PATH, version: WHISPER_VERSION });
119 | await downloadWhisperModel({ folder: WHISPER_PATH, model: WHISPER_MODEL });
120 |
121 | // Read arguments for filename if given else process all files in the directory
122 | const hasArgs = process.argv.length > 2;
123 |
124 | if (!hasArgs) {
125 | await processDirectory(path.join(process.cwd(), "public"));
126 | process.exit(0);
127 | }
128 |
129 | for (const arg of process.argv.slice(2)) {
130 | const fullPath = path.join(process.cwd(), arg);
131 | const stat = lstatSync(fullPath);
132 |
133 | if (stat.isDirectory()) {
134 | await processDirectory(fullPath);
135 | continue;
136 | }
137 |
138 | console.log(`Processing file ${fullPath}`);
139 | const directory = path.dirname(fullPath);
140 | const fileName = path.basename(fullPath);
141 | await processVideo(fullPath, fileName, directory);
142 | }
143 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2018",
4 | "module": "commonjs",
5 | "jsx": "react-jsx",
6 | "strict": true,
7 | "noEmit": true,
8 | "lib": ["es2015"],
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "noUnusedLocals": true
13 | },
14 | "exclude": ["remotion.config.ts"]
15 | }
16 |
--------------------------------------------------------------------------------
/whisper-config.mjs:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 |
3 | // Where to install Whisper.cpp to
4 | export const WHISPER_PATH = path.join(process.cwd(), "whisper.cpp");
5 |
6 | // The version of Whisper.cpp to install
7 | export const WHISPER_VERSION = "1.6.0";
8 |
9 | // Which model to use.
10 | // | Model | Disk | Mem |
11 | // |------------------|--------|----------|
12 | // | tiny | 75 MB | ~390 MB |
13 | // | tiny.en | 75 MB | ~390 MB |
14 | // | base | 142 MB | ~500 MB |
15 | // | base.en | 142 MB | ~500 MB |
16 | // | small | 466 MB | ~1.0 GB |
17 | // | small.en | 466 MB | ~1.0 GB |
18 | // | medium | 1.5 GB | ~2.6 GB |
19 | // | medium.en | 1.5 GB | ~2.6 GB |
20 | // | large-v1 | 2.9 GB | ~4.7 GB |
21 | // | large-v2 | 2.9 GB | ~4.7 GB |
22 | // | large-v3 | 2.9 GB | ~4.7 GB |
23 | // | large-v3-turbo | 1.5 GB | ~4.7 GB | // Only supported from Whisper.cpp 1.7.2 and higher
24 | // | large | 2.9 GB | ~4.7 GB |
25 |
26 | /**
27 | * @type {import('@remotion/install-whisper-cpp').WhisperModel}
28 | */
29 | export const WHISPER_MODEL = "medium.en";
30 |
31 | // Language to transcribe
32 | // If you set another language than 'en', remove .en from the WHISPER_MODEL
33 | // List of languages: https://github.com/openai/whisper/blob/main/whisper/tokenizer.py
34 | /**
35 | * @type {import('@remotion/install-whisper-cpp').Language}
36 | */
37 | export const WHISPER_LANG = "en";
38 |
--------------------------------------------------------------------------------