Tools
Tools: Generating 21 Multilingual Promo Videos from React Code with Remotion
2026-02-25
0 views
admin
Architecture -- A Standalone Subproject ## Iterating with Remotion Studio ## Composition Versioning -- 5 Formats ## Multilingual Support and Cultural Adaptation ## Reusable Scene Components ## Batch Rendering -- Bundle Once, Render 21 Times ## Dynamic vs. Fixed Duration ## Lessons Learned ## Use OffthreadVideo ## ESM Path Resolution Requires fileURLToPath ## Strictly Sync 3D Library Versions ## No Magic Numbers ## Sequential Rendering Is Reliable ## Conclusion I run Amida-san, an online Amidakuji (lottery ladder) service. For the Product Hunt launch, I needed a 30-second demo video, a 15-second clip for X, and a calmer pitch version -- each in English, Japanese, Chinese, and Korean. You can see the actual videos on the Amida-san top page. A promo video embedded on the landing page, showing the Amidakuji animation scene. Doing this manually in a video editor was not realistic. Instead, I wrote everything as React components. Remotion converts JSX to MP4 -- scenes are functions, translations are props, and rendering is reproducible. The result: a single npm run video:render:all command generates 21 videos (5 versions x 4 languages + 1) in about 15 minutes. Remotion ships with heavy dependencies: @remotion/bundler, @remotion/renderer, Webpack toolchain, and ffmpeg bindings. Mixing these into a Vite-based React app bloats node_modules and causes version conflicts. I separated it into a subproject with its own package.json under a video/ directory. During development, npx remotion studio launches a browser-based editor. You select compositions from the sidebar and scrub the timeline to inspect frame by frame. Remotion Studio. Composition list on the left, timeline with Sequence layout at the bottom. You can switch props from the sidebar too -- change language from ja to en and preview the English version immediately. Code changes hot-reload instantly, so adjusting spring parameters or tweaking text takes seconds. Rendering to MP4 takes time, but this fast edit-preview cycle gives you the same trial-and-error experience as a video editor. You can fine-tune timing and verify animations entirely in the browser. Different platforms require different videos. I prepared 5 versions: Each version is an independent composition component (MainPromoA.tsx through MainPromoE.tsx) that assembles scenes with Remotion's <Sequence>. Here's the structure for the 30-second version C: All durations are computed from named constants. FPS is 30, defined once in renderConfig.ts and referenced everywhere. No magic numbers. Versions D and E use "subtle" variants of the same scenes (IntroSceneSubtle, FeaturesGridSceneSubtle) with gentler spring physics. Each scene receives a language prop ('en' | 'ja' | 'zh' | 'ko') and selects display content from a local text map. The Japanese text is not a literal translation of English. "Can your team trust the lottery?" becomes a culturally natural question in each language. Video text has short screen time, so natural-sounding expressions take priority over literal translations. Fonts also switch per language: On macOS, these CJK fonts come pre-installed. For CI environments (Linux), consider installing the corresponding fonts or using @remotion/google-fonts. The 5 compositions share 10 scene components: The Amidakuji animation in Amida2DScene draws SVG <line> elements using Remotion's spring() physics. Vertical lines appear one by one, and each player's horizontal bridges extend outward. Layout constants like PLAYER_COUNT, STAGE_WIDTH, and PLAYER_BRIDGES are centralized in amidaConfig.ts. Both the animation scene and ball-drop scene reference the same config, preventing rendering inconsistencies. The key to batch processing is minimizing bundle() calls. Remotion's bundle() runs Webpack internally, making it expensive. The batch script runs bundle() once and loops renderMedia() 21 times against the same bundle. The composition matrix is a flat array enumerating all combinations. The first entry, SimpleTitle, is a short intro clip with just the service logo and title, used for concatenation with other videos. This is the "+1" in "5 x 4 + 1". Each render injects MP4 title and description metadata from the shared videoMetadata.ts module. This metadata is used both in the MP4 file tags and in JSON-LD structured data on the website. Update one place, and both reflect the change. Versions A and B embed actual web app screen recordings. Recording length varies by language (the Japanese demo is longer), so calculateMetadata resolves duration dynamically at render time. When video files are missing (CI environment or fresh clone), fallback constants are used: Versions C, D, and E use fixed durations (30 * FPS, 15 * FPS) without calculateMetadata. Fixed durations make frame counts predictable and timing adjustments easier. Remotion's <Video> decodes on the main thread, which can cause dropped frames during rendering. OffthreadVideo renders each frame independently on a separate thread. I switched after experiencing frame drops in the web app screen recording sections. Node.js ESM modules don't have __dirname. Render scripts resolve paths like this: An easy-to-forget pattern when migrating from CommonJS. The video project imports Three.js components from the main app. Minor version mismatches between the two package.json files caused "Cannot read properties of undefined" errors. Pin both major and minor versions across projects. Durations, intervals, and frame counts are all named constants. A single config file controls timing across all compositions. I tried parallel rendering but it exhausted RAM and crashed. Single bundle + sequential rendering is slower but reliably generates all videos. With this setup, adding a new language means adding entries to each scene's text map and appending 4 lines to the composition matrix. Adding a new version just requires one composition file that rearranges existing scenes. All 21 videos, 5 versions, 4 languages -- the entire pipeline regenerates in about 15 minutes on an M1 MacBook Pro. If your product needs multilingual, multi-format video assets, writing videos as React components is a viable approach. Want to change a tagline, swap a font, or add a 6th version? That's where the initial setup cost pays off. If you need a fair, participatory lottery, try Amida-san! Originally published at shusukedev.com Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
project-root/ package.json # Main app (Vite + React) video/ package.json # Remotion subproject src/ compositions/ # 6 compositions scenes/ # 10 reusable scenes constants/ # Render config, Amidakuji config components/ # UI parts (Caption, etc.) scripts/ generate-promo-video.ts # Single video render render-all-videos.ts # 21-video batch render src/ shared/ videoMetadata.ts # Metadata shared between main app and video Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
project-root/ package.json # Main app (Vite + React) video/ package.json # Remotion subproject src/ compositions/ # 6 compositions scenes/ # 10 reusable scenes constants/ # Render config, Amidakuji config components/ # UI parts (Caption, etc.) scripts/ generate-promo-video.ts # Single video render render-all-videos.ts # 21-video batch render src/ shared/ videoMetadata.ts # Metadata shared between main app and video COMMAND_BLOCK:
project-root/ package.json # Main app (Vite + React) video/ package.json # Remotion subproject src/ compositions/ # 6 compositions scenes/ # 10 reusable scenes constants/ # Render config, Amidakuji config components/ # UI parts (Caption, etc.) scripts/ generate-promo-video.ts # Single video render render-all-videos.ts # 21-video batch render src/ shared/ videoMetadata.ts # Metadata shared between main app and video COMMAND_BLOCK:
export const MainPromoC: React.FC<Props> = ({ language }) => { const introDuration = 3 * FPS; const amidaDuration = 11 * FPS; const ballDuration = 10 * FPS; const featuresDuration = 3 * FPS; const ctaDuration = 3 * FPS; return ( <AbsoluteFill> <Audio src={staticFile("audio/bgm.mp3")} volume={0.4} /> <Sequence from={0} durationInFrames={introDuration}> <IntroSceneV2 language={language} /> </Sequence> <Sequence from={introDuration} durationInFrames={amidaDuration}> <Amida2DScene language={language} showWebAppDemo videoDelayFrames={6 * FPS} /> </Sequence> <Sequence from={introDuration + amidaDuration} durationInFrames={ballDuration} > <BallAnimationScene language={language} showWebAppDemo showAnimation /> </Sequence> <Sequence from={introDuration + amidaDuration + ballDuration} durationInFrames={featuresDuration} > <FeaturesGridScene language={language} /> </Sequence> <Sequence from={introDuration + amidaDuration + ballDuration + featuresDuration} durationInFrames={ctaDuration} > <CTAScene language={language} /> </Sequence> </AbsoluteFill> );
}; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
export const MainPromoC: React.FC<Props> = ({ language }) => { const introDuration = 3 * FPS; const amidaDuration = 11 * FPS; const ballDuration = 10 * FPS; const featuresDuration = 3 * FPS; const ctaDuration = 3 * FPS; return ( <AbsoluteFill> <Audio src={staticFile("audio/bgm.mp3")} volume={0.4} /> <Sequence from={0} durationInFrames={introDuration}> <IntroSceneV2 language={language} /> </Sequence> <Sequence from={introDuration} durationInFrames={amidaDuration}> <Amida2DScene language={language} showWebAppDemo videoDelayFrames={6 * FPS} /> </Sequence> <Sequence from={introDuration + amidaDuration} durationInFrames={ballDuration} > <BallAnimationScene language={language} showWebAppDemo showAnimation /> </Sequence> <Sequence from={introDuration + amidaDuration + ballDuration} durationInFrames={featuresDuration} > <FeaturesGridScene language={language} /> </Sequence> <Sequence from={introDuration + amidaDuration + ballDuration + featuresDuration} durationInFrames={ctaDuration} > <CTAScene language={language} /> </Sequence> </AbsoluteFill> );
}; COMMAND_BLOCK:
export const MainPromoC: React.FC<Props> = ({ language }) => { const introDuration = 3 * FPS; const amidaDuration = 11 * FPS; const ballDuration = 10 * FPS; const featuresDuration = 3 * FPS; const ctaDuration = 3 * FPS; return ( <AbsoluteFill> <Audio src={staticFile("audio/bgm.mp3")} volume={0.4} /> <Sequence from={0} durationInFrames={introDuration}> <IntroSceneV2 language={language} /> </Sequence> <Sequence from={introDuration} durationInFrames={amidaDuration}> <Amida2DScene language={language} showWebAppDemo videoDelayFrames={6 * FPS} /> </Sequence> <Sequence from={introDuration + amidaDuration} durationInFrames={ballDuration} > <BallAnimationScene language={language} showWebAppDemo showAnimation /> </Sequence> <Sequence from={introDuration + amidaDuration + ballDuration} durationInFrames={featuresDuration} > <FeaturesGridScene language={language} /> </Sequence> <Sequence from={introDuration + amidaDuration + ballDuration + featuresDuration} durationInFrames={ctaDuration} > <CTAScene language={language} /> </Sequence> </AbsoluteFill> );
}; CODE_BLOCK:
const texts = { en: { hook: "Can your team trust the lottery?", proof: "Trusted by 10,000+ users", solution: "Everyone participates. Nobody cheats.", }, ja: { hook: "Are the drawings really fair?", proof: "Used by 10,000+ people", solution: "Everyone joins. Zero fraud.", }, zh: { hook: "Is your drawing really fair?", proof: "Trusted by 10,000+ users", solution: "Everyone participates. Zero cheating.", }, ko: { hook: "Is your lottery really fair?", proof: "Used by 10,000+ people", solution: "Everyone participates. Zero fraud.", },
}; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const texts = { en: { hook: "Can your team trust the lottery?", proof: "Trusted by 10,000+ users", solution: "Everyone participates. Nobody cheats.", }, ja: { hook: "Are the drawings really fair?", proof: "Used by 10,000+ people", solution: "Everyone joins. Zero fraud.", }, zh: { hook: "Is your drawing really fair?", proof: "Trusted by 10,000+ users", solution: "Everyone participates. Zero cheating.", }, ko: { hook: "Is your lottery really fair?", proof: "Used by 10,000+ people", solution: "Everyone participates. Zero fraud.", },
}; CODE_BLOCK:
const texts = { en: { hook: "Can your team trust the lottery?", proof: "Trusted by 10,000+ users", solution: "Everyone participates. Nobody cheats.", }, ja: { hook: "Are the drawings really fair?", proof: "Used by 10,000+ people", solution: "Everyone joins. Zero fraud.", }, zh: { hook: "Is your drawing really fair?", proof: "Trusted by 10,000+ users", solution: "Everyone participates. Zero cheating.", }, ko: { hook: "Is your lottery really fair?", proof: "Used by 10,000+ people", solution: "Everyone participates. Zero fraud.", },
}; CODE_BLOCK:
const style: React.CSSProperties = { fontFamily: language === "ja" ? "Hiragino Kaku Gothic ProN, sans-serif" : language === "zh" ? '"PingFang SC", "Microsoft YaHei", sans-serif' : language === "ko" ? '"Malgun Gothic", "Apple SD Gothic Neo", sans-serif' : 'Arial, "Helvetica Neue", sans-serif',
}; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const style: React.CSSProperties = { fontFamily: language === "ja" ? "Hiragino Kaku Gothic ProN, sans-serif" : language === "zh" ? '"PingFang SC", "Microsoft YaHei", sans-serif' : language === "ko" ? '"Malgun Gothic", "Apple SD Gothic Neo", sans-serif' : 'Arial, "Helvetica Neue", sans-serif',
}; CODE_BLOCK:
const style: React.CSSProperties = { fontFamily: language === "ja" ? "Hiragino Kaku Gothic ProN, sans-serif" : language === "zh" ? '"PingFang SC", "Microsoft YaHei", sans-serif' : language === "ko" ? '"Malgun Gothic", "Apple SD Gothic Neo", sans-serif' : 'Arial, "Helvetica Neue", sans-serif',
}; CODE_BLOCK:
const lineProgress = spring({ frame: frame - playerIndex * 8, fps, config: { damping: 100, stiffness: 200 },
}); <line x1={x} y1={VERTICAL_LINE_START_Y} x2={x} y2={VERTICAL_LINE_START_Y + lineHeight * lineProgress} stroke="#009999" strokeWidth={5} strokeLinecap="round"
/>; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const lineProgress = spring({ frame: frame - playerIndex * 8, fps, config: { damping: 100, stiffness: 200 },
}); <line x1={x} y1={VERTICAL_LINE_START_Y} x2={x} y2={VERTICAL_LINE_START_Y + lineHeight * lineProgress} stroke="#009999" strokeWidth={5} strokeLinecap="round"
/>; CODE_BLOCK:
const lineProgress = spring({ frame: frame - playerIndex * 8, fps, config: { damping: 100, stiffness: 200 },
}); <line x1={x} y1={VERTICAL_LINE_START_Y} x2={x} y2={VERTICAL_LINE_START_Y + lineHeight * lineProgress} stroke="#009999" strokeWidth={5} strokeLinecap="round"
/>; COMMAND_BLOCK:
const renderAllVideos = async () => { // Bundle once const bundled = await bundle({ entryPoint: path.join(VIDEO_DIR, "src", "index.ts"), webpackOverride: (config) => config, }); // Render 21 times sequentially for (const comp of compositions) { const composition = await selectComposition({ serveUrl: bundled, id: comp.id, inputProps: comp.inputProps, }); await renderMedia({ composition, serveUrl: bundled, codec: "h264", outputLocation: path.join(OUTPUT_DIR, comp.filename), inputProps: comp.inputProps, }); }
}; Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
const renderAllVideos = async () => { // Bundle once const bundled = await bundle({ entryPoint: path.join(VIDEO_DIR, "src", "index.ts"), webpackOverride: (config) => config, }); // Render 21 times sequentially for (const comp of compositions) { const composition = await selectComposition({ serveUrl: bundled, id: comp.id, inputProps: comp.inputProps, }); await renderMedia({ composition, serveUrl: bundled, codec: "h264", outputLocation: path.join(OUTPUT_DIR, comp.filename), inputProps: comp.inputProps, }); }
}; COMMAND_BLOCK:
const renderAllVideos = async () => { // Bundle once const bundled = await bundle({ entryPoint: path.join(VIDEO_DIR, "src", "index.ts"), webpackOverride: (config) => config, }); // Render 21 times sequentially for (const comp of compositions) { const composition = await selectComposition({ serveUrl: bundled, id: comp.id, inputProps: comp.inputProps, }); await renderMedia({ composition, serveUrl: bundled, codec: "h264", outputLocation: path.join(OUTPUT_DIR, comp.filename), inputProps: comp.inputProps, }); }
}; CODE_BLOCK:
const compositions = [ { id: "SimpleTitle", filename: "simple-title.mp4", inputProps: {} }, { id: "MainPromoC", filename: "promo-c-en.mp4", inputProps: { language: "en" }, }, { id: "MainPromoC", filename: "promo-c-ja.mp4", inputProps: { language: "ja" }, }, // ... all 21 entries
]; Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const compositions = [ { id: "SimpleTitle", filename: "simple-title.mp4", inputProps: {} }, { id: "MainPromoC", filename: "promo-c-en.mp4", inputProps: { language: "en" }, }, { id: "MainPromoC", filename: "promo-c-ja.mp4", inputProps: { language: "ja" }, }, // ... all 21 entries
]; CODE_BLOCK:
const compositions = [ { id: "SimpleTitle", filename: "simple-title.mp4", inputProps: {} }, { id: "MainPromoC", filename: "promo-c-en.mp4", inputProps: { language: "en" }, }, { id: "MainPromoC", filename: "promo-c-ja.mp4", inputProps: { language: "ja" }, }, // ... all 21 entries
]; COMMAND_BLOCK:
<Composition id="MainPromoA" component={MainPromoA} durationInFrames={300} // Initial placeholder calculateMetadata={async ({ props }) => { const durations = await calculatePromoDuration(props.language); const totalDuration = introDuration + durations.amidaSceneDuration + durations.ballSceneDuration + featuresDuration + ctaDuration; return { durationInFrames: totalDuration, props: { ...props, ...durations }, }; }}
/> Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
<Composition id="MainPromoA" component={MainPromoA} durationInFrames={300} // Initial placeholder calculateMetadata={async ({ props }) => { const durations = await calculatePromoDuration(props.language); const totalDuration = introDuration + durations.amidaSceneDuration + durations.ballSceneDuration + featuresDuration + ctaDuration; return { durationInFrames: totalDuration, props: { ...props, ...durations }, }; }}
/> COMMAND_BLOCK:
<Composition id="MainPromoA" component={MainPromoA} durationInFrames={300} // Initial placeholder calculateMetadata={async ({ props }) => { const durations = await calculatePromoDuration(props.language); const totalDuration = introDuration + durations.amidaSceneDuration + durations.ballSceneDuration + featuresDuration + ctaDuration; return { durationInFrames: totalDuration, props: { ...props, ...durations }, }; }}
/> CODE_BLOCK:
export const FALLBACK_AMIDA_SCENE_DURATION_FRAMES = 18 * FPS; // 540
export const FALLBACK_BALL_SCENE_DURATION_FRAMES = 12 * FPS; // 360 Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
export const FALLBACK_AMIDA_SCENE_DURATION_FRAMES = 18 * FPS; // 540
export const FALLBACK_BALL_SCENE_DURATION_FRAMES = 12 * FPS; // 360 CODE_BLOCK:
export const FALLBACK_AMIDA_SCENE_DURATION_FRAMES = 18 * FPS; // 540
export const FALLBACK_BALL_SCENE_DURATION_FRAMES = 12 * FPS; // 360 CODE_BLOCK:
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); CODE_BLOCK:
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
how-totutorialguidedev.toailinuxswitchnode