REMOTION LOCAL VOICEOVER ON MAC: ORIGINAL TESTED WORKFLOW Checked October 9, 2026 Guide: https://www.murmurtts.com/blog/resources/remotion-local-voiceover-mac RESULT AND SCOPE The original 7.12-second Murmur WAV rendered as 214 frames at 30 fps, 1080x1920, H.264/yuv420p with AAC stereo 48,000 Hz. Video-track duration: 7.133333 seconds; container duration: 7.147 seconds; file size: 395,762 bytes. Full audio/video decode succeeded and representative frames were inspected. An independent 2.25-second input produced 68 frames and a 2.266667-second video track. The original speech was generated earlier by a development helper reporting Murmur 1.0.18, Qwen3-TTS Base / Ryan; released-binary parity was not verified. No assistant model was invoked. Labels summarize the workflow and are not word-aligned captions. No networking-disabled or listener-preference test ran. REQUIREMENTS Murmur setup/license and local automation ready on an Apple Silicon Mac with macOS 15+. Separately, Node.js/npm plus ffprobe and ffmpeg available in Terminal. The render environment used Node 26.10.0, npm 11.19.1 and FFmpeg 9.0.2. All Remotion packages are pinned 4.0.534; React/react-dom 19.3.0. Initial npm installation and Remotion browser download need network access. This example references only local narration and original text. Keep the public asset folder local; exclude private audio before sharing source or web assets. 1. NEW FOLDER AND ORIGINAL NARRATION Run these commands one step at a time in a fresh directory. Stop if mkdir fails or the folder already exists; do not continue into an existing project. Do not paste this entire document as a shell script. Select an installed model and its own preset voice from your Mac; the fixture's model/voice are not universal defaults. mkdir remotion-voiceover-01 cd remotion-voiceover-01 mkdir src public murmur status --json murmur models --json murmur voices --model qwen3-base --json cat > script.txt <<'SCRIPT' Start with one clear idea. Write a short script. Generate the voice on your Mac, then build the motion around it. SCRIPT murmur generate --input script.txt --model qwen3-base --voice Ryan \ --language EN-US --speed 1.0 --output narration.wav --json cp -n narration.wav public/narration.wav Alternatively export your approved WAV in Murmur and copy it to public/narration.wav in the fresh folder. Measure your actual take below. 2. PIN THE RENDER DEPENDENCIES cat > package.json <<'JSON' { "name": "local-remotion-voiceover-fixture", "version": "1.0.0", "private": true, "scripts": { "preview": "remotion studio src/index.tsx --props=props.json", "render": "remotion render src/index.tsx LocalVoiceover local-voiceover.mp4 --props=props.json --codec=h264 --audio-codec=aac --pixel-format=yuv420p --concurrency=2 --overwrite=false" }, "dependencies": { "@remotion/cli": "4.0.534", "@remotion/media": "4.0.534", "remotion": "4.0.534", "react": "19.3.0", "react-dom": "19.3.0" } } JSON npm install --registry=https://registry.npmjs.org --no-audit --no-fund Keep the generated lockfile with reusable project source. Use npm ci for later installs from that lockfile. No cloud renderer or speech API is configured here. 3. SAVE THE DURATION PREPARATION SCRIPT The helper rejects an invalid duration and refuses to replace props.json. cat > prepare.mjs <<'JS' import {execFileSync} from 'node:child_process'; import {writeFileSync} from 'node:fs'; const durationSeconds = Number(execFileSync('ffprobe', [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', 'public/narration.wav' ], {encoding: 'utf8'}).trim()); if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { throw new Error('Narration must have a positive finite duration.'); } const props = {audioFile: 'narration.wav', durationSeconds}; writeFileSync('props.json', JSON.stringify(props, null, 2) + '\n', {flag: 'wx'}); console.log(JSON.stringify({...props, fps: 30, durationInFrames: Math.ceil(durationSeconds * 30)})); JS 4. SAVE THE ORIGINAL REACT COMPOSITION Audio starts at zero and stays mounted across visual stages. Motion reads the frame number, so it is deterministic when Remotion seeks directly to a frame. The three equal stages are a small integration example; use actual cue points and checked captions for a public product demo. cat > src/index.tsx <<'TSX' import React from 'react'; import {Audio} from '@remotion/media'; import {AbsoluteFill, Composition, interpolate, registerRoot, staticFile, useCurrentFrame, useVideoConfig} from 'remotion'; type Props = {audioFile: string; durationSeconds: number}; const fps = 30; const LocalVoiceover = ({audioFile}: Props) => { const frame = useCurrentFrame(); const {durationInFrames} = useVideoConfig(); const progress = frame / Math.max(1, durationInFrames - 1); const stage = Math.min(2, Math.floor(progress * 3)); const stageStart = Math.floor(stage * durationInFrames / 3); const entrance = interpolate(frame - stageStart, [0, 12], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }); const headings = ['WRITE', 'VOICE', 'MOTION']; const details = ['Start with one clear idea.', 'Narration from your Mac.', 'Build around the real duration.']; return