├── .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 | Animated Remotion Logo 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 | --------------------------------------------------------------------------------