remotion-video
npx skills add https://github.com/wshuyi/remotion-video-skill --skill remotion-video
Agent 安装分布
Skill 文档
Remotion Video
ç¨ React 以ç¼ç¨æ¹å¼å建 MP4 è§é¢çæ¡æ¶ã
æ ¸å¿æ¦å¿µ
- Composition – è§é¢çå®ä¹ï¼å°ºå¯¸ã帧çãæ¶é¿ï¼
- useCurrentFrame() – è·åå½å帧å·ï¼é©±å¨å¨ç»
- interpolate() – å°å¸§å·æ å°å°ä»»æå¼ï¼ä½ç½®ãéæåº¦çï¼
- spring() – ç©çå¨ç»ææ
- – æ¶é´è½´ä¸æåç»ä»¶
å¿«éå¼å§
å建æ°é¡¹ç®
npx create-video@latest
éæ©æ¨¡æ¿åï¼
cd <project-name>
npm run dev # å¯å¨ Remotion Studio é¢è§
项ç®ç»æ
my-video/
âââ src/
â âââ Root.tsx # æ³¨åææ Composition
â âââ HelloWorld.tsx # è§é¢ç»ä»¶
â âââ index.ts # å
¥å£
âââ public/ # éæèµæºï¼é³é¢ãå¾çï¼
âââ remotion.config.ts # é
ç½®æä»¶
âââ package.json
åºç¡ç»ä»¶ç¤ºä¾
æå°è§é¢ç»ä»¶
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
export const MyVideo = () => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
return (
<AbsoluteFill style={{ backgroundColor: "white", justifyContent: "center", alignItems: "center" }}>
<h1 style={{ fontSize: 100 }}>Frame {frame}</h1>
</AbsoluteFill>
);
};
注å Composition
// Root.tsx
import { Composition } from "remotion";
import { MyVideo } from "./MyVideo";
export const RemotionRoot = () => {
return (
<Composition
id="MyVideo"
component={MyVideo}
durationInFrames={150} // 5ç§ @ 30fps
fps={30}
width={1920}
height={1080}
/>
);
};
å¨ç»æå·§
interpolate – 弿 å°
import { interpolate, useCurrentFrame } from "remotion";
const frame = useCurrentFrame();
// 0-30帧ï¼éæåº¦ 0â1
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateRight: "clamp", // è¶
åºèå´æ¶é³å¶
});
// ä½ç§»å¨ç»
const translateY = interpolate(frame, [0, 30], [50, 0]);
spring – ç©çå¨ç»
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = spring({
frame,
fps,
config: { damping: 10, stiffness: 100 },
});
Sequence – æ¶é´ç¼æ
import { Sequence } from "remotion";
<>
<Sequence from={0} durationInFrames={60}>
<Intro />
</Sequence>
<Sequence from={60} durationInFrames={90}>
<MainContent />
</Sequence>
<Sequence from={150}>
<Outro />
</Sequence>
</>
AI è¯é³è§£è¯´éæ
为è§é¢æ·»å AI è¯é³è§£è¯´ï¼å®ç°é³è§é¢åæ¥ãæ¯æä¸¤ç§æ¹æ¡ï¼
| æ¹æ¡ | ä¼ç¹ | ç¼ºç¹ | ç¡¬ä»¶è¦æ± | æ¨è度 |
|---|---|---|---|---|
| MiniMax TTS | äºç«¯å éãé度æå¿«ï¼<3ç§ï¼ãé³è´¨ä¼ç§ | æå符计费 | æ | âââ é¦é |
| Edge TTS | é¶é ç½®ãå è´¹ | åºå®é³è²ãæ æ³èªå®ä¹ | æ | ââ |
æ¹æ¡éæ©æµç¨
1. é¦é MiniMax TTS
- æ£æµ API Key æ¯å¦é
ç½®
- æµè¯è°ç¨æ¯å¦æ£å¸¸ï¼ä½é¢å
è¶³ï¼
- 妿æå â ä½¿ç¨ MiniMax
2. MiniMax ä¸å¯ç¨æ¶
â éå Edge TTSï¼ä½¿ç¨é¢è®¾é³è² zh-CN-YunyangNeuralï¼
æ¹æ¡ä¸ï¼MiniMax TTSï¼æ¨èï¼
äºç«¯ API æ¹æ¡ï¼æ éæ¬å° GPUï¼çæé度æå¿«ï¼é³è²å éææä¼ç§ã
é ç½®
- 注å https://www.minimax.io ï¼å½é çï¼æ https://platform.minimaxi.com ï¼å½å çï¼
- è·å API Key
- å¨ MiniMax Audio ä¸ä¼ é³é¢å éé³è²ï¼è·å voice_id
API å·®å¼
| çæ¬ | API åå | 说æ |
|---|---|---|
| å½é ç | api.minimax.io |
æ¨èï¼ç¨³å® |
| å½å ç | api.minimaxi.com |
éå½å è´¦å· |
â ï¸ å¸¸è§é误ï¼api.minimax.chat æ¯é误çååï¼ä¼è¿å “invalid api key”ã请确认使ç¨ä¸è¡¨ä¸çæ£ç¡®ååã
çæèæ¬
ä½¿ç¨ scripts/generate_audio_minimax.py çæé³é¢ï¼æ¯æï¼
- æç¹ç»ä½ï¼å·²åå¨çé³é¢æä»¶èªå¨è·³è¿
- 宿¶è¿åº¦ï¼æ¾ç¤ºçæè¿åº¦ï¼é¿å è«ç¶çå¾
- èªå¨æ´æ°é ç½®ï¼çæå®æåèªå¨æ´æ° Remotion çåºæ¯é ç½®
# 设置ç¯å¢åé
export MINIMAX_API_KEY="your_api_key"
export MINIMAX_VOICE_ID="your_voice_id"
# è¿è¡èæ¬
python scripts/generate_audio_minimax.py
ä»·æ ¼åèï¼2025å¹´ï¼
| 模å | ä»·æ ¼ |
|---|---|
| speech-02-hd | Â¥0.1/åå符 |
| speech-02-turbo | Â¥0.05/åå符 |
â ï¸ MiniMax TTS 踩åç»éª
| é®é¢ | åå | è§£å³æ¹æ¡ |
|---|---|---|
invalid api key |
使ç¨äºé误ç API åå | å½é
çç¨ api.minimax.ioï¼å½å
çç¨ api.minimaxi.com |
config.ts è¯æ³é误 Syntax error "n" |
Python èæ¬å¨ f-string ä¸ç¨ ",\\n".join() 产çäºåé¢é \n èéçæ£æ¢è¡ |
è§ä¸æ¹ãPython çæ TypeScript 注æäºé¡¹ã |
| é¿æ¶é´æ è¿åº¦æ¾ç¤º | åå°æ§è¡å½ä»¤çä¸å°è¾åº | åå°æ§è¡èæ¬ï¼æç¨ tail -f 宿¶æ¥çæ¥å¿ |
Python çæ TypeScript 注æäºé¡¹
â éè¯¯åæ³ï¼å¨ f-string ä¸ä½¿ç¨ \n ä¼äº§çåé¢éå符
# è¿ä¼å¨çæçæä»¶ä¸åå
¥åé¢ç \n å符串ï¼è鿢è¡ï¼
content = f'export const SCENES = [{",\\n".join(items)}];'
â æ£ç¡®åæ³ï¼åå¼å¤çåç¬¦ä¸²æ¼æ¥
# å
ç¨çæ£çæ¢è¡ç¬¦æ¼æ¥
scenes_content = ",\n".join(items) # å¨ f-string å¤é¨æ¼æ¥
# åæ¾å
¥æ¨¡æ¿
content = f'''export const SCENES = [
{scenes_content}
];'''
æ¹æ¡äºï¼Edge TTS
æ éç¹æ®ç¡¬ä»¶ï¼å®å ¨å è´¹ï¼éåä¸éè¦å éé³è²çåºæ¯ã
å®è£
pip install edge-tts
æ¨èè¯é³
| è¯é³ ID | åç§° | 飿 ¼ |
|---|---|---|
| zh-CN-YunyangNeural | äºæ¬ | ä¸ä¸æé³è ï¼æ¨èï¼ |
| zh-CN-XiaoxiaoNeural | ææ | 温æèªç¶ |
| zh-CN-YunxiNeural | äºå¸ | é³å å°å¹´ |
çæèæ¬
ä½¿ç¨ scripts/generate_audio_edge.py çæé³é¢ï¼
python scripts/generate_audio_edge.py
Remotion é³é¢åæ¥
import { Audio, Sequence, staticFile } from "remotion";
// é³é¢é
ç½®ï¼æ ¹æ®çæçæ¶é¿ï¼
const audioConfig = [
{ id: "01-intro", file: "01-intro.mp3", frames: 450 },
{ id: "02-main", file: "02-main.mp3", frames: 600 },
];
// 计ç®èµ·å§å¸§
const sceneStarts = audioConfig.reduce((acc, _, i) => {
if (i === 0) return [0];
return [...acc, acc[i - 1] + audioConfig[i - 1].frames];
}, [] as number[]);
// åºæ¯æ¸²æ
{audioConfig.map((scene, i) => (
<Sequence key={scene.id} from={sceneStarts[i]} durationInFrames={scene.frames}>
<SceneComponent />
<Audio src={staticFile(scene.file)} />
</Sequence>
))}
æç¨ç±»è§é¢æ¶æï¼åºæ¯é©±å¨ï¼
æç¨ã讲解类è§é¢çæ ¸å¿æ¶æï¼é³é¢é©±å¨åºæ¯åæ¢ã
æ¶ææ¦è§
é³é¢èæ¬ â TTS çæ â audioConfig.ts â åºæ¯ç»ä»¶ â è§é¢æ¸²æ
å ³é®ææ³ï¼
- é³é¢å³å®æ¶é¿ï¼æ¯ä¸ªåºæ¯çæç»æ¶é´ç±é³é¢é¿åº¦å³å®
- åºæ¯å³ç« èï¼ä¸ä¸ªæ¦å¿µ = ä¸ä¸ªåºæ¯ = 䏿®µé³é¢
- é
ç½®å³ççï¼
audioConfig.tsæ¯é³ç»åæ¥çå䏿°æ®æº
audioConfig.ts 模æ¿
åè§ templates/audioConfig.tsï¼å
å«ï¼
- SceneConfig æ¥å£å®ä¹
- SCENES æ°ç»
- getSceneStart() 计ç®å½æ°
- TOTAL_FRAMES å FPS 常é
åºæ¯åæ¢ Hook
import { useCurrentFrame } from "remotion";
import { SCENES } from "./audioConfig";
// æ ¹æ®å½å帧å·è¿ååºæ¯ç´¢å¼
const useCurrentSceneIndex = () => {
const frame = useCurrentFrame();
let accumulated = 0;
for (let i = 0; i < SCENES.length; i++) {
accumulated += SCENES[i].durationInFrames;
if (frame < accumulated) return i;
}
return SCENES.length - 1;
};
// 使ç¨
const sceneIndex = useCurrentSceneIndex();
const currentScene = SCENES[sceneIndex];
ä¸»åºæ¯ç»ä»¶æ¨¡å¼
import { AbsoluteFill, Audio, Sequence, staticFile, useVideoConfig } from "remotion";
import { ThreeCanvas } from "@remotion/three";
import { SCENES, getSceneStart, TOTAL_FRAMES } from "./audioConfig";
export const TutorialVideo: React.FC = () => {
const { width, height } = useVideoConfig();
const sceneIndex = useCurrentSceneIndex();
const currentScene = SCENES[sceneIndex];
return (
<AbsoluteFill style={{ backgroundColor: "#1a1a2e" }}>
{/* 3D å
容 */}
<ThreeCanvas width={width} height={height} camera={{ position: [0, 0, 4], fov: 50 }}>
{/* æ ¹æ® sceneIndex 渲æä¸ååºæ¯ */}
{sceneIndex === 0 && <Scene01Intro />}
{sceneIndex === 1 && <Scene02Concept />}
{sceneIndex === 2 && <Scene03Demo />}
</ThreeCanvas>
{/* é³é¢åæ¥ - æ¯ä¸ªåºæ¯ä¸ä¸ª Sequence */}
{SCENES.map((scene, idx) => (
<Sequence key={scene.id} from={getSceneStart(idx)} durationInFrames={scene.durationInFrames}>
<Audio src={staticFile(`audio/${scene.audioFile}`)} />
</Sequence>
))}
{/* UI å±ï¼æ é¢ + è¿åº¦ */}
<div style={{ position: "absolute", top: 40, left: 0, right: 0, textAlign: "center" }}>
<h1 style={{ color: "white", fontSize: 42 }}>æç¨æ é¢</h1>
</div>
<div style={{ position: "absolute", bottom: 60, left: 60 }}>
<span style={{ color: "white" }}>{currentScene?.title}</span>
</div>
{/* è¿åº¦æ¡ */}
<div style={{ position: "absolute", bottom: 30, left: 60, right: 60, height: 4, backgroundColor: "rgba(255,255,255,0.2)" }}>
<div style={{ width: `${((sceneIndex + 1) / SCENES.length) * 100}%`, height: "100%", backgroundColor: "#3498DB" }} />
</div>
</AbsoluteFill>
);
};
Root.tsx 使ç¨å¨æå¸§æ°
import { Composition } from "remotion";
import { TutorialVideo } from "./TutorialVideo";
import { TOTAL_FRAMES } from "./audioConfig";
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="Tutorial"
component={TutorialVideo}
fps={30}
durationInFrames={TOTAL_FRAMES} // ä» audioConfig 卿è·å
width={1920}
height={1080}
/>
);
};
â ï¸ æç¨è§é¢è¸©åç»éª
| é®é¢ | åå | è§£å³æ¹æ¡ |
|---|---|---|
| åºæ¯åæ¢ç硬 | ç´æ¥åæ¢æ è¿æ¸¡ | ç¨ spring/interpolate æ·»å å ¥åºå¨ç» |
| 3D å 容ä¸é³é¢ä¸åæ¥ | 硬ç¼ç å¸§æ° | æææ¶é¿ä» audioConfig 读å |
| æ¸²ææ¶ WebGL å´©æº | å¤ä¸ª ThreeCanvas åæ¶åå¨ | ç¨ sceneIndex æ¡ä»¶æ¸²æï¼åæ¶åªæä¸ä¸ª 3D åºæ¯ |
| è§é¢å¤ªç®ç¥ | åªæä¸ä¸ªå¤§åºæ¯ | ä¸ä¸ªæ¦å¿µ = ä¸ä¸ªåºæ¯ç»ä»¶ï¼åå±è®²è§£ |
åºæ¯ç»ä»¶è®¾è®¡åå
- åä¸èè´£ï¼æ¯ä¸ªåºæ¯ç»ä»¶åªè´è´£ä¸ä¸ªæ¦å¿µ
- ç¬ç«å¨ç»ï¼æ¯ä¸ªåºæ¯æèªå·±ç useCurrentFrame()ï¼å¨ç»ä» 0 å¼å§
- å»¶è¿åºç°ï¼ç¨ delay åæ°æ§å¶å ç´ ä¾æ¬¡åºç°
- ç¸æºéé ï¼ä¸ååºæ¯å¯è½éè¦ä¸åç¸æºä½ç½®
// åºæ¯ç»ä»¶ç¤ºä¾
const Scene02Input: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// å
¥åºå¨ç»
const gridScale = spring({ frame, fps, config: { damping: 15 } });
return (
<group>
<PixelGrid position={[0, 0, 0]} scale={gridScale * 1.5} />
</group>
);
};
ç¸æºæ§å¶å¨æ¨¡å¼
import { useThree } from "@react-three/fiber";
// â
æ¨èåæ³ï¼ç´æ¥è®¾ç½®ç¸æºä½ç½®ï¼é¿å
æå¼å¯¼è´çæç»æå¨
const CameraController: React.FC<{ sceneIndex: number }> = ({ sceneIndex }) => {
const { camera } = useThree();
const cameraSettings: Record<number, [number, number, number]> = {
0: [0, 0, 4], // å¼åºï¼æ£é¢
1: [0, 0, 3], // è¾å
¥å±ï¼é è¿
2: [-0.5, 0, 3.5], // å·ç§¯ï¼åå·¦
3: [0, 0, 5], // æ»ç»ï¼æè¿å
¨æ¯
};
const target = cameraSettings[sceneIndex] || [0, 0, 4];
// ç´æ¥è®¾ç½®ä½ç½®ï¼ä¸ç¨æå¼
camera.position.set(target[0], target[1], target[2]);
camera.lookAt(0, 0, 0);
return null;
};
â ï¸ ä¸è¦ç¨ position += (target - position) * factor è¿ç§åæ³ï¼æ°¸è¿æ æ³ç²¾ç¡®æ¶æï¼ä¼å¯¼è´ç»é¢æç»æå¨ã详è§ãð¨ 3D åºæ¯å¸¸è§é·é± – é·é±1ãã
常ç¨åè½
æ·»å è§é¢/é³é¢
import { Video, Audio, staticFile } from "remotion";
// ä½¿ç¨ public/ ç®å½ä¸çæä»¶
<Video src={staticFile("background.mp4")} />
<Audio src={staticFile("music.mp3")} volume={0.5} />
// å¤é¨ URL
<Video src="https://example.com/video.mp4" />
æ·»å å¾ç
import { Img, staticFile } from "remotion";
<Img src={staticFile("logo.png")} style={{ width: 200 }} />
åæ°åè§é¢ï¼å¨ææ°æ®ï¼
// å®ä¹ props schema
const myCompSchema = z.object({
title: z.string(),
bgColor: z.string(),
});
export const MyVideo: React.FC<z.infer<typeof myCompSchema>> = ({ title, bgColor }) => {
return (
<AbsoluteFill style={{ backgroundColor: bgColor }}>
<h1>{title}</h1>
</AbsoluteFill>
);
};
// æ³¨åæ¶ä¼ å
¥é»è®¤å¼
<Composition
id="MyVideo"
component={MyVideo}
schema={myCompSchema}
defaultProps={{ title: "Hello", bgColor: "#ffffff" }}
...
/>
渲æè¾åº
CLI 渲æ
# 渲æä¸º MP4
npx remotion render MyVideo out/video.mp4
# æå®ç¼ç å¨
npx remotion render --codec=h264 MyVideo out/video.mp4
# WebM æ ¼å¼
npx remotion render --codec=vp8 MyVideo out/video.webm
# GIF
npx remotion render --codec=gif MyVideo out/video.gif
# ä»
é³é¢
npx remotion render --codec=mp3 MyVideo out/audio.mp3
# å¾çåºå
npx remotion render --sequence MyVideo out/frames
# å帧éæå¾
npx remotion still MyVideo --frame=30 out/thumbnail.png
å¸¸ç¨æ¸²æåæ°
| åæ° | 说æ |
|---|---|
--codec |
h264, h265, vp8, vp9, gif, mp3, wav ç |
--crf |
è´¨é (0-51ï¼è¶å°è¶å¥½ï¼é»è®¤18) |
--props |
JSON æ ¼å¼ä¼ å ¥ props |
--scale |
缩æ¾å å |
--concurrency |
å¹¶è¡æ¸²ææ° |
é«çº§åè½
åå¹ (@remotion/captions)
npm i @remotion/captions @remotion/install-whisper-cpp
npx remotion-install-whisper-cpp # å®è£
Whisper
import { transcribe } from "@remotion/install-whisper-cpp";
const { transcription } = await transcribe({
inputPath: "audio.mp3",
whisperPath: whisperCppPath,
model: "medium",
});
ææ¾å¨åµå ¥ Web åºç¨
npm i @remotion/player
import { Player } from "@remotion/player";
import { MyVideo } from "./MyVideo";
<Player
component={MyVideo}
durationInFrames={150}
fps={30}
compositionWidth={1920}
compositionHeight={1080}
style={{ width: "100%" }}
controls
inputProps={{ title: "Dynamic Title" }}
/>
AWS Lambda 渲æ
npm i @remotion/lambda
npx remotion lambda policies role # 设置 IAM
npx remotion lambda sites create # é¨ç½²ç«ç¹
npx remotion lambda render <site-url> MyVideo # 渲æ
3D è§é¢å¶ä½ï¼@remotion/threeï¼
ä½¿ç¨ React Three Fiber å¨ Remotion ä¸å建 3D å¨ç»è§é¢ã
éç¨åºæ¯
| åºæ¯ | 说æ | ç¤ºä¾ |
|---|---|---|
| 产åå±ç¤º | 3D 模åæè½¬ãæè§£å¨ç» | ææºäº§åå®£ä¼ ç |
| è§è²å¨ç» | å¡éè§è²è®²è§£ãæ äºåè¿° | è²å¿ç§æ®è§é¢ |
| æ°æ®å¯è§å | 3D å¾è¡¨ãç©ºé´æ°æ® | å°çä¿¡æ¯ã建çå±ç¤º |
| Logo å¨ç» | åç 3D Logo å ¥åº | ç头çå°¾ |
å®è£
npm i three @react-three/fiber @remotion/three @types/three
宿¹æ¨¡æ¿ï¼æ¨èæ°æï¼ï¼
npx create-video@latest --template three
åºç¡ç¤ºä¾
import { ThreeCanvas } from "@remotion/three";
import { useCurrentFrame, useVideoConfig, interpolate, spring } from "remotion";
import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
// 3D åºæ¯ç»ä»¶
const My3DScene = () => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const camera = useThree((state) => state.camera);
// è®¾ç½®ç¸æº
useEffect(() => {
camera.position.set(0, 0, 5);
camera.lookAt(0, 0, 0);
}, [camera]);
// æè½¬å¨ç»
const rotation = interpolate(frame, [0, durationInFrames], [0, Math.PI * 2]);
// å¼¹æ§å
¥åº
const scale = spring({ frame, fps, config: { damping: 10, stiffness: 100 } });
return (
<mesh rotation={[0, rotation, 0]} scale={scale}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="royalblue" />
</mesh>
);
};
// è§é¢ç»ä»¶
export const My3DVideo = () => {
const { width, height } = useVideoConfig();
return (
<ThreeCanvas width={width} height={height}>
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} />
<My3DScene />
</ThreeCanvas>
);
};
å è½½ GLTF 模å
import { useGLTF } from "@react-three/drei";
import { useCurrentFrame, interpolate } from "remotion";
const Model = () => {
const frame = useCurrentFrame();
const { scene } = useGLTF("/models/character.glb");
const rotation = interpolate(frame, [0, 150], [0, Math.PI * 2]);
return <primitive object={scene} rotation={[0, rotation, 0]} scale={0.5} />;
};
å®è£ dreiï¼React Three Fiber å·¥å ·åºï¼ï¼
npm i @react-three/drei
è§é¢ä½ä¸º 3D 纹ç
import { ThreeCanvas, useVideoTexture } from "@remotion/three";
import { staticFile, useVideoConfig } from "remotion";
const VideoOnMesh = () => {
const { width, height } = useVideoConfig();
const videoTexture = useVideoTexture(staticFile("/video.mp4"));
return (
<ThreeCanvas width={width} height={height}>
<mesh>
<planeGeometry args={[4, 3]} />
{videoTexture && <meshBasicMaterial map={videoTexture} />}
</mesh>
</ThreeCanvas>
);
};
æ¸²ææ¶ä½¿ç¨ useOffthreadVideoTexture() ç¡®ä¿å¸§ç²¾ç¡®ï¼
import { useOffthreadVideoTexture } from "@remotion/three";
const texture = useOffthreadVideoTexture({ src: staticFile("/video.mp4") });
3D è§è²ç»åæå·§
ç¨åºç¡å ä½ä½ç»åè§è²ï¼æ éä¸ä¸å»ºæ¨¡ï¼ï¼
// ç®åå¡éè§è²ï¼å¤´ + èº«ä½ + åè¢
const CartoonCharacter = ({ emotion = "happy" }) => {
const frame = useCurrentFrame();
// 表æ
æ§å¶
const eyeScale = emotion === "happy" ? 1 : 0.5;
const mouthRotation = emotion === "happy" ? 0 : Math.PI;
// èµ°è·¯å¨ç»ï¼è
¿é¨æå¨
const legSwing = Math.sin(frame * 0.2) * 0.3;
return (
<group>
{/* å¤´é¨ - çä½ */}
<mesh position={[0, 1.5, 0]}>
<sphereGeometry args={[0.5, 32, 32]} />
<meshStandardMaterial color="#FFE4C4" />
</mesh>
{/* èº«ä½ - è¶åä½ */}
<mesh position={[0, 0.5, 0]}>
<capsuleGeometry args={[0.3, 0.8, 16, 32]} />
<meshStandardMaterial color="#4169E1" />
</mesh>
{/* å·¦è
¿ */}
<mesh position={[-0.15, -0.3, 0]} rotation={[legSwing, 0, 0]}>
<cylinderGeometry args={[0.08, 0.08, 0.6]} />
<meshStandardMaterial color="#333" />
</mesh>
{/* å³è
¿ */}
<mesh position={[0.15, -0.3, 0]} rotation={[-legSwing, 0, 0]}>
<cylinderGeometry args={[0.08, 0.08, 0.6]} />
<meshStandardMaterial color="#333" />
</mesh>
</group>
);
};
â ï¸ è¸©åç»éª
WebGL ä¸ä¸ææº¢åº
é®é¢ï¼å¤ä¸ª 3D åºæ¯åæ¶æ¸²ææ¶æ¥é Error creating WebGL context
åå ï¼æµè§å¨éå¶ WebGL ä¸ä¸ææ°éï¼é常 8-16 个ï¼
è§£å³æ¹æ¡ï¼
- 渲æé
ç½®ï¼ä½¿ç¨
angleOpenGL 弿
// remotion.config.ts
export default {
chromiumOptions: {
gl: "angle", // æ "angle-egl"
},
};
CLI æ¸²ææ¶ï¼
npx remotion render --gl=angle MyVideo out.mp4
- æå è½½åºæ¯ï¼åªæ¸²æå½å帧éè¿ç 3D å 容
import { useCurrentFrame } from "remotion";
const LazyScene = ({ sceneStart, sceneDuration, children }) => {
const frame = useCurrentFrame();
const buffer = 30; // ç¼å² 30 帧
// åªå¨åºæ¯æ¶é´èå´ Â± buffer å
渲æ
const shouldRender =
frame >= sceneStart - buffer &&
frame <= sceneStart + sceneDuration + buffer;
if (!shouldRender) {
return null; // 䏿¸²æï¼éæ¾ WebGL ä¸ä¸æ
}
return <>{children}</>;
};
// 使ç¨
<Sequence from={0} durationInFrames={150}>
<LazyScene sceneStart={0} sceneDuration={150}>
<Scene1 />
</LazyScene>
</Sequence>
<Sequence from={150} durationInFrames={150}>
<LazyScene sceneStart={150} sceneDuration={150}>
<Scene2 />
</LazyScene>
</Sequence>
æå¡ç«¯æ¸²æé ç½®
æå¡ç«¯æ¸²æï¼SSRï¼å¿
é¡»é
ç½® gl é项ï¼
// renderMedia() / renderFrames() / getCompositions()
await renderMedia({
composition,
serveUrl,
outputLocation: "out.mp4",
chromiumOptions: {
gl: "angle",
},
});
Sequence å ç useCurrentFrame
<Sequence> å
é¨ç useCurrentFrame() è¿åçæ¯ç¸å¯¹äº Sequence å¼å§ç帧å·ï¼ä¸æ¯å
¨å±å¸§å·ã
<Sequence from={60} durationInFrames={90}>
<MyScene /> {/* è¿é useCurrentFrame() ä» 0 å¼å§ï¼ä¸æ¯ 60 */}
</Sequence>
è¿é¶èµæº
| èµæº | ç¨é | 龿¥ |
|---|---|---|
| Mixamo | å 费骨骼å¨ç»åº | https://www.mixamo.com |
| Sketchfab | å è´¹/ä»è´¹ 3D 模å | https://sketchfab.com |
| Ready Player Me | èæäººç©çæ | https://readyplayer.me |
| Spline | å¨çº¿ 3D è®¾è®¡å·¥å · | https://spline.design |
| gltfjsx | GLTF 转 React ç»ä»¶ | npx gltfjsx model.glb |
è¿é¶æ¹å
- Blender â GLTFï¼ç¨ Blender 建模ï¼å¯¼åº GLTF æ ¼å¼ï¼ç¨
useGLTFå è½½ - Mixamo å¨ç»ï¼ä¸è½½ FBX å¨ç»ï¼è½¬æ¢ä¸º GLTFï¼ç¨
useAnimationsææ¾ - Spline 设计ï¼å¨ Spline 设计 3D åºæ¯ï¼ç¨
@splinetool/r3f-splineå¯¼å ¥
3Blue1Brown 飿 ¼æåï¼æç¨ç±»è§é¢ï¼
é对æç¨ã讲解类è§é¢ï¼åé´ 3Blue1Brown çå¯è§å设计ååã
æ ¸å¿ç念
3B1B å
æ ¸ï¼è®©è§ä¼ãèªå·±åç°ãï¼è䏿¯ã被åç¥çæ¡ã
| åå | 说æ | ç¤ºä¾ |
|---|---|---|
| Why â What | å æé®ä¸ºä»ä¹ï¼åå±ç¤ºæ¯ä»ä¹ | “å¦ä½è¯å«æåæ°åï¼” â å±ç¤ºç¥ç»ç½ç» |
| 鿥æå»º | å ç´ ä¸ä¸ªä¸ªåºç°ï¼ä¸è¦æ´ä½æ·¡å ¥ | ç¥ç»å 便¬¡ç¹äº®ï¼èéåæ¶åºç° |
| é¢è²æè¯ä¹ | é¢è²ä¼ 达信æ¯ï¼ä¸æ¯è£ 饰 | è=æ£ã红=è´ãé»=é«äº® |
| æ°å¼å ·è±¡å | æ¾ç¤ºå ·ä½æ°å让æ½è±¡æ¦å¿µè½å° | åç´ å¼ 0.7ãæ¿æ´»å¼ 0.92 |
| 2D ä¼å | æ¸ æ°ä¼å äºç«é ·ï¼å¿ è¦æ¶æç¨ 3D | ç½ç»ç»æç¨ 2Dï¼ç©ºé´æ°æ®ç¨ 3D |
é è²æ¹æ¡
// 3B1B 飿 ¼é
è²ï¼è¯ä¹åï¼
const COLORS_3B1B = {
background: "#000000", // 纯é»èæ¯
positive: "#58C4DD", // èè² - æ£æé/æ£å
negative: "#FF6B6B", // çº¢è² - è´æé/è´å
highlight: "#FFFF00", // é»è² - å½åç¦ç¹/é«äº®
result: "#83C167", // ç»¿è² - ç»æ/æ£ç¡®
text: "#FFFFFF", // ç½è² - æå
neutral: "#888888", // ç°è² - 䏿§/æªæ¿æ´»
accent: "#FF8C00", // æ©è² - 强è°
};
// 使ç¨ç¤ºä¾
<meshStandardMaterial
color={weight > 0 ? COLORS_3B1B.positive : COLORS_3B1B.negative}
emissive={isHighlighted ? COLORS_3B1B.highlight : "#000"}
emissiveIntensity={isHighlighted ? 0.3 : 0}
/>
2D/3D æ··åçç¥
| å 容类å | æ¨è维度 | åå |
|---|---|---|
| ç½ç»ç»æå¾ | 2D | 屿¬¡æ¸ æ°ï¼æäºæ 注 |
| æ°æ®æµå | 2D + å¨ç»ç®å¤´ | 强è°é¡ºåºåå æ |
| å·ç§¯æä½ | 2D 俯è§å¾ | ç½æ ¼å¯¹é½ï¼æ°å¼å¯è§ |
| ç¹å¾å¾å å | 2.5Dï¼éè§ï¼ | å±ç¤ºæ·±åº¦/ééæ° |
| 3D ç©ä½è¯å« | 3D | å 容æ¬èº«æ¯ 3D |
2D 模å¼å®ç°ï¼ä½¿ç¨æ£äº¤ç¸æº + æå¹³å ä½ä½
import { OrthographicCamera } from "@react-three/drei";
// æ£äº¤ç¸æº = æ éè§åå½¢ = 2D æè§
<OrthographicCamera makeDefault position={[0, 0, 10]} zoom={100} />
// æå¹³å ä½ä½
<mesh>
<planeGeometry args={[1, 1]} /> {/* 2D å¹³é¢ */}
<meshBasicMaterial color={color} />
</mesh>
鿥æå»ºå¨ç»
æ ¸å¿ï¼ç¨ delay åæ°æ§å¶å
ç´ ä¾æ¬¡åºç°
// æ¹éå
ç´ é个åºç°
const StaggeredGroup: React.FC<{
children: React.ReactNode[];
delayPerItem?: number
}> = ({ children, delayPerItem = 8 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<>
{React.Children.map(children, (child, i) => {
const delay = i * delayPerItem;
const progress = spring({
frame: frame - delay,
fps,
config: { damping: 12, stiffness: 100 },
});
if (frame < delay) return null;
return (
<group scale={Math.max(0, progress)} opacity={progress}>
{child}
</group>
);
})}
</>
);
};
// 使ç¨
<StaggeredGroup delayPerItem={10}>
<Neuron position={[0, 0, 0]} />
<Neuron position={[1, 0, 0]} />
<Neuron position={[2, 0, 0]} />
</StaggeredGroup>
æ°å¼æ ç¾ç»ä»¶
import { Text } from "@react-three/drei";
const ValueLabel: React.FC<{
value: number;
position: [number, number, number];
fontSize?: number;
}> = ({ value, position, fontSize = 0.15 }) => {
// æ ¹æ®å¼éæ©é¢è²
const color = value > 0.5 ? COLORS_3B1B.positive :
value < -0.5 ? COLORS_3B1B.negative :
COLORS_3B1B.neutral;
return (
<Text
position={position}
fontSize={fontSize}
color={color}
anchorX="center"
anchorY="middle"
font="/fonts/JetBrainsMono-Regular.ttf" // ç宽åä½
>
{value.toFixed(2)}
</Text>
);
};
é«äº®ç¦ç¹ç»ä»¶
// èå²é«äº®æ¡ - å¼å¯¼æ³¨æå
const FocusBox: React.FC<{
position: [number, number, number];
size: [number, number];
label?: string;
}> = ({ position, size, label }) => {
const frame = useCurrentFrame();
const pulse = 1 + Math.sin(frame * 0.15) * 0.08;
return (
<group position={position}>
{/* é«äº®æ¡ */}
<mesh scale={[pulse, pulse, 1]}>
<planeGeometry args={size} />
<meshBasicMaterial
color={COLORS_3B1B.highlight}
transparent
opacity={0.2}
/>
</mesh>
{/* è¾¹æ¡ */}
<lineSegments>
<edgesGeometry args={[new THREE.PlaneGeometry(...size)]} />
<lineBasicMaterial color={COLORS_3B1B.highlight} linewidth={2} />
</lineSegments>
{/* æ ç¾ */}
{label && (
<Text position={[0, size[1] / 2 + 0.2, 0]} fontSize={0.12} color={COLORS_3B1B.highlight}>
{label}
</Text>
)}
</group>
);
};
èæ¬æ°åæåï¼æç¨ç±»ï¼
â 宣å¸å¼ï¼é¿å ï¼ï¼
"é¦å
æ¯è¾å
¥å±ãå¾åæ¯ä¸ä¸ªæ°åç©éµã"
"æ¥ä¸æ¥æ¯å·ç§¯å±ãå·ç§¯æ ¸å¨å¾å䏿»å¨ã"
â æ¢ç´¢å¼ï¼æ¨èï¼ï¼
"ä½ è½è½»æ¾è®¤åºè¿æ¯æ°å 7ï¼ä½ä½ è½æè¿°ä½ æ¯æä¹åå°çåï¼
ï¼åé¡¿ 1 ç§ï¼
è¿æ£æ¯ç¥ç»ç½ç»è¦è§£å³çé®é¢ã
让æä»¬å
ççè®¡ç®æºãçå°ãçæ¯ä»ä¹ââ
ï¼æ°åç½æ ¼é个æ¾ç¤ºï¼
䏿¯å¾åï¼èæ¯ 784 个æ°åã
é£ä¹é®é¢æ¥äºï¼å¦ä½ä»è¿å æ°åä¸è¯å«åº 7ï¼"
èæ¬ç»ææ¨¡æ¿ï¼
1. ð¯ æåºé®é¢ï¼10%ï¼
- ç¨è§ä¼è½å
±é¸£çé®é¢å¼åº
- "ä½ ææ²¡ææ³è¿..."
2. ð¤ ç´è§çæµï¼15%ï¼
- å¼å¯¼è§ä¼æèå¯è½çæ¹æ¡
- "ä¹è®¸æä»¬å¯ä»¥..."
3. ð 鿥éªè¯ï¼50%ï¼
- 䏿¥æ¥å±ç¤ºæºå¶
- æ¯ä¸æ¥é½åçã为ä»ä¹è¿æ ·è®¾è®¡ã
4. ð å½¢å¼åï¼15%ï¼
- å±ç¤ºæ°å¦å
¬å¼ï¼å¯éï¼
- å°ç´è§è½¬å为精确æè¿°
5. ð¬ å顾æ»ç»ï¼10%ï¼
- 宿´æµç¨å¿«éåæ¾
- å¼ºè°æ ¸å¿æ´è§
â ï¸ å¸¸è§è¯¯åº
| è¯¯åº | é®é¢ | æ¹è¿ |
|---|---|---|
| 3D ç«æ | æè½¬ãéè§åæ£æ³¨æå | ç¨æç®åçè§è§è¡¨è¾¾ |
| é¢è²éæ | 红绿èåªæ¯è£ 饰 | 建ç«é¢è²-å«ä¹æ å° |
| æ´ä½åºç° | è§ä¼ä¸ç¥éçåªé | é个å ç´ + é«äº®å¼å¯¼ |
| åªè¯´ What | è§ä¼ä¸çè§£è®¾è®¡å¨æº | å é® Why åå±ç¤º What |
| ä¿¡æ¯è¿è½½ | ä¸ä¸ªåºæ¯å¡å¤ªå¤æ¦å¿µ | ä¸ä¸ªåºæ¯ä¸ä¸ªæ¦å¿µ |
è¿ç¨å¨ç»æ¨¡å¼ï¼Process Animationï¼
æ ¸å¿ç念ï¼ä¸åªå±ç¤ºãæ¯ä»ä¹ãï¼æ´è¦å±ç¤ºãæä¹ç®ãã让è§ä¼äº²ç¼çå°æ°æ®å¦ä½æµå¨ã计ç®å¦ä½åçã
éç¨åºæ¯
| åºæ¯ | 说æ | ç¤ºä¾ |
|---|---|---|
| ç®æ³å¯è§å | å±ç¤ºæ¯ä¸æ¥æä½ | æåºãæç´¢ãå¾éå |
| æ°å¦å ¬å¼æ¨å¯¼ | é项å±å¼è®¡ç® | ç©éµä¹æ³ãå·ç§¯è¿ç® |
| æ°æ®å¤çæµç¨ | è¾å ¥â忢âè¾åº | CNN ååä¼ æãæ°æ®æ¸ æ´ |
| å³çè¿ç¨ | æ¯è¾ãçéãæç»éæ© | æ± ååæå¤§å¼ãsoftmax |
å¨ç»æ¨¡å¼åç±»
éæå±ç¤º â ç»æå¨ç» â è¿ç¨å¨ç»
â â â
æªå¾ å
ç´ åºç° 计ç®è¿ç¨
æ·¡å
¥æ·¡åº æ°æ®æµå¨
ç¸æºç§»å¨ ç»æåå
¥
è¿ç¨å¨ç»ç»ä»¶åº
1. è®¡ç®æ¥éª¤å±ç¤ºï¼StepByStepï¼
// 鿥æ¾ç¤ºè®¡ç®è¿ç¨
const StepByStepCalc: React.FC<{
steps: string[]; // ["1Ã0.5", "+ 0Ã0.3", "+ 1Ã(-0.2)", "= 0.3"]
startFrame: number;
framesPerStep?: number;
}> = ({ steps, startFrame, framesPerStep = 20 }) => {
const frame = useCurrentFrame();
return (
<div style={{ fontFamily: "monospace", fontSize: 24, color: "white" }}>
{steps.map((step, i) => {
const stepStart = startFrame + i * framesPerStep;
const opacity = interpolate(frame, [stepStart, stepStart + 10], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const isResult = i === steps.length - 1;
return (
<span
key={i}
style={{
opacity,
color: isResult ? COLORS.result : COLORS.text,
fontWeight: isResult ? "bold" : "normal",
}}
>
{step}{" "}
</span>
);
})}
</div>
);
};
2. æ°å¼é£å ¥å¨ç»ï¼ValueFlyInï¼
// 计ç®ç»æé£å
¥ç®æ ä½ç½®
const ValueFlyIn: React.FC<{
value: number;
from: [number, number, number];
to: [number, number, number];
startFrame: number;
duration?: number;
}> = ({ value, from, to, startFrame, duration = 30 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame: frame - startFrame,
fps,
config: { damping: 15, stiffness: 80 },
});
if (frame < startFrame) return null;
const position: [number, number, number] = [
from[0] + (to[0] - from[0]) * progress,
from[1] + (to[1] - from[1]) * progress,
from[2] + (to[2] - from[2]) * progress,
];
const scale = 1.5 - 0.5 * progress; // é£è¡æ¶æ¾å¤§ï¼è½å°æ¶ç¼©å°
return (
<Text
position={position}
fontSize={0.12 * scale}
color={COLORS.result}
anchorX="center"
anchorY="middle"
>
{value.toFixed(1)}
</Text>
);
};
3. åºåé«äº®æ¯è¾ï¼CompareHighlightï¼
// å¤ä¸ªå¼ä¾æ¬¡æ¯è¾ï¼èåºè
é«äº®
const CompareHighlight: React.FC<{
values: number[];
positions: [number, number, number][];
startFrame: number;
framesPerCompare?: number;
}> = ({ values, positions, startFrame, framesPerCompare = 15 }) => {
const frame = useCurrentFrame();
// 计ç®å½åæ¯è¾è¿åº¦
const compareIndex = Math.floor((frame - startFrame) / framesPerCompare);
const maxIndex = values.indexOf(Math.max(...values));
return (
<>
{values.map((value, i) => {
const isComparing = i <= compareIndex && i <= maxIndex;
const isWinner = compareIndex >= values.length - 1 && i === maxIndex;
return (
<group key={i} position={positions[i]}>
<mesh>
<boxGeometry args={[0.2, 0.2, 0.02]} />
<meshStandardMaterial
color={isWinner ? COLORS.result : isComparing ? COLORS.highlight : COLORS.dim}
emissive={isWinner ? COLORS.result : "#000"}
emissiveIntensity={isWinner ? 0.5 : 0}
/>
</mesh>
<Text position={[0, 0, 0.02]} fontSize={0.08} color="#000">
{value}
</Text>
</group>
);
})}
</>
);
};
4. æ»å¨çªå£ï¼SlidingWindowï¼
// å·ç§¯æ ¸/æ± åçªå£æ»å¨
const SlidingWindow: React.FC<{
gridSize: number; // è¾å
¥ç½æ ¼å¤§å°
windowSize: number; // çªå£å¤§å° (3 for 3x3)
stride: number; // æ¥å¹
currentStep: number; // å½åæ¥éª¤ (0, 1, 2, ...)
onPositionChange?: (row: number, col: number) => void;
}> = ({ gridSize, windowSize, stride, currentStep }) => {
const outputSize = Math.floor((gridSize - windowSize) / stride) + 1;
const totalSteps = outputSize * outputSize;
const step = Math.min(currentStep, totalSteps - 1);
const row = Math.floor(step / outputSize) * stride;
const col = (step % outputSize) * stride;
// çªå£ä½ç½®ï¼ç¸å¯¹äºç½æ ¼ä¸å¿ï¼
const pixelSize = 0.12;
const gap = 0.01;
const offset = (gridSize / 2 - 0.5) * (pixelSize + gap);
const windowOffset = (windowSize / 2 - 0.5) * (pixelSize + gap);
const x = col * (pixelSize + gap) - offset + windowOffset;
const y = row * (pixelSize + gap) - offset + windowOffset;
return (
<mesh position={[x, y, 0.05]}>
<boxGeometry args={[windowSize * pixelSize + (windowSize - 1) * gap,
windowSize * pixelSize + (windowSize - 1) * gap, 0.02]} />
<meshStandardMaterial
color={COLORS.negative}
transparent
opacity={0.6}
emissive={COLORS.negative}
emissiveIntensity={0.3}
/>
</mesh>
);
};
èæ¬æ°åæåï¼è¿ç¨å¨ç»çï¼
å ³é®è½¬åï¼èæ¬éè¦é åå¨ç»èå¥ï¼ç»å¨ç»ãçç½æ¶é´ãã
â ä¼ ç»èæ¬ï¼ä¿¡æ¯å¯éï¼ï¼
"å·ç§¯æ ¸å¨å¾å䏿»å¨ï¼æ¯å°ä¸ä¸ªä½ç½®å°±åç¹ä¹è¿ç®ï¼å¾å°ä¸ä¸ªæ°å¼ã"
ï¼ä¸å¥è¯å¸¦è¿ï¼è§ä¼è¿æ²¡çæ¸
åçäºä»ä¹ï¼
â è¿ç¨å¨ç»èæ¬ï¼çç½é åï¼ï¼
"让æä»¬ççå·ç§¯æ¯æä¹è®¡ç®çã"
ï¼åé¡¿ - çªå£ç§»å¨å°ä½ç½®ï¼
"å·ç§¯æ ¸è¦çäºè¿ 9 个åç´ ã"
ï¼åé¡¿ - é«äº® 3x3 åºåï¼
"æä»¬ææ¯ä¸ªåç´ å¼ï¼å对åºçæéç¸ä¹..."
ï¼åé¡¿ - 鿥æ¾ç¤ºä¹æ³ï¼
"ç¶åæææç»æå èµ·æ¥ã"
ï¼åé¡¿ - æ¾ç¤ºæ±åè¿ç¨ï¼
"å¾å°çè¿ä¸ªæ°åï¼å°±åå
¥ç¹å¾å¾ç对åºä½ç½®ã"
ï¼åé¡¿ - ç»æé£å
¥ï¼
"第ä¸ä¸ªä½ç½®å®æäºãæ¥ä¸æ¥ï¼çªå£å峿»å¨ä¸æ ¼..."
ï¼å éå±ç¤ºåç»æ¥éª¤ï¼
æ¶é´åé 建议
| 详ç»ç¨åº¦ | 馿¬¡å®æ´å±ç¤º | éå¤å é | éç¨åºæ¯ |
|---|---|---|---|
| æè¯¦ç» | 3-4 ç§/æ¥ | 0.5 ç§/æ¥ | æ ¸å¿æ¦å¿µé¦æ¬¡åºç° |
| ä¸ç | 2 ç§/æ¥ | 0.3 ç§/æ¥ | è¾ å©æ¦å¿µ |
| å¿«é | 1 ç§/æ¥ | éªè¿ | 已解éè¿çéå¤ |
示ä¾ï¼å·ç§¯åºæ¯æ¶é´åé
æ»æ¶é¿ï¼~25 ç§
0-3s: å¼å
¥ï¼"让æä»¬ççå·ç§¯æ¯æä¹è®¡ç®ç"ï¼
3-12s: 第 1 次å·ç§¯ï¼å®æ´è¯¦ç»å±ç¤ºï¼
- çªå£ç§»å¨ (1s)
- é«äº®åºå (1s)
- 计ç®è¿ç¨ (4s)
- ç»æé£å
¥ (2s)
- 解说æç½ (1s)
12-18s: 第 2-3 次å·ç§¯ï¼ä¸çé度ï¼ç®å解说ï¼
18-23s: å©ä½ä½ç½®ï¼å¿«éæ»å¨ï¼ä»
æ¾ç¤ºç»æï¼
23-25s: å±ç¤ºå®æ´ç¹å¾å¾
â ï¸ è¿ç¨å¨ç»è¸©åç»éª
| é®é¢ | åå | è§£å³æ¹æ¡ |
|---|---|---|
| å¨ç»å¤ªå¿«ç䏿¸ | æ¶é´åé ä¸è¶³ | å¢å å ³é®æ¥éª¤çå¸§æ° |
| 解说ä¸å¨ç»ä¸åæ¥ | èæ¬æ²¡æçç½ | éåèæ¬ï¼å å ¥åé¡¿æ è®° |
| ä¿¡æ¯è¿è½½ | 䏿¬¡å±ç¤ºå¤ªå¤ | åé¶æ®µï¼å ç»æï¼åè¿ç¨ |
| éå¤å 容æ è | æ¯æ¬¡é½è¯¦ç»å±ç¤º | 馿¬¡è¯¦ç» + åç»å é |
| æ°å¼å¤ªå°çä¸è§ | 3D æå渲æé®é¢ | ç¨ 2D HTML overlay |
| ç¸æºæç»æå¨ | æå¼æ°¸ä¸æ¶æ | è§ä¸æ¹ãç¸æºæ§å¶é·é±ã |
| å¾åæè½¬90度 | è¡ååæ æ å°åäº | è§ä¸æ¹ãç½æ ¼åæ é·é±ã |
| è¿åº¦æ¾ç¤ºå¥½å å% | progress åéæª clamp | Math.min(1, (frame - start) / duration) |
| ç¹å¾å¾åªæè²åæ æ°å¼ | ç»ä»¶ç¼ºå°æ°å¼æ¾ç¤ºåè½ | æ·»å values + showValues åæ° |
è¿åº¦åéå¿ é¡» clamp
// â é误ï¼åºæ¯æç»æ¶é´å¯è½è¿è¶
颿ï¼progress ä¼åæ 5000%
const calcProgress = frame > 30 ? (frame - 30) / 60 : 0;
// â
æ£ç¡®ï¼éå¶å¨ [0, 1] èå´
const calcProgress = frame > 30 ? Math.min(1, (frame - 30) / 60) : 0;
ç¹å¾å¾æ¾ç¤ºè®¡ç®ç»æ
// FeatureMap ç»ä»¶åºæ¯ææ¾ç¤ºæ°å¼
<FeatureMap
position={[2, 0, 0]}
size={0.6}
count={1}
color={COLORS.result}
filledCells={filledCount}
gridSize={6}
values={[2, -1, 0, 3, ...]} // æ¯ä¸ªæ ¼åç计ç®ç»æ
showValues // å¯ç¨æ°å¼æ¾ç¤º
/>
ð¨ 3D åºæ¯å¸¸è§é·é±
é·é± 1ï¼ç¸æºæç»æå¨
çç¶ï¼ç»é¢ä¸ç´å¾®å¾®æ¾å¤§-ç¼©å°æå¨
éè¯¯åæ³ï¼
// â æ°¸è¿æ æ³ç²¾ç¡®å°è¾¾ç®æ ï¼å¯¼è´æç»å¾®æå¨
const CameraController = ({ targetZ }) => {
const { camera } = useThree();
const frame = useCurrentFrame();
useEffect(() => {
camera.position.z += (targetZ - camera.position.z) * 0.05;
}, [frame]);
return null;
};
æ£ç¡®åæ³ï¼
// â
æ¹æ¡Aï¼ä½¿ç¨ spring å¨ç»ï¼æ¨èï¼
const CameraController = ({ targetZ, transitionFrame = 0 }) => {
const { camera } = useThree();
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const z = spring({
frame: frame - transitionFrame,
fps,
from: camera.position.z,
to: targetZ,
config: { damping: 20, stiffness: 100 },
});
camera.position.z = z;
return null;
};
// â
æ¹æ¡Bï¼ç´æ¥è®¾ç½®ï¼æ è¿æ¸¡ï¼
const CameraController = ({ targetZ }) => {
const { camera } = useThree();
camera.position.set(0, 0, targetZ);
camera.lookAt(0, 0, 0);
return null;
};
// â
æ¹æ¡Cï¼æå¼ä½å éå¼
useEffect(() => {
const delta = targetZ - camera.position.z;
if (Math.abs(delta) < 0.001) {
camera.position.z = targetZ; // æ¥è¿æ¶ç´æ¥è®¾ç½®
} else {
camera.position.z += delta * 0.1;
}
}, [frame]);
é·é± 2ï¼ç½æ ¼å¾åæè½¬90度
çç¶ï¼æ¬åºæ¾ç¤ºä¸ºæ£å¸¸æ¹åçå¾åï¼å¦æ°å7ï¼è¢«æè½¬äº90度
æ ¹å ï¼å¾åå¤çä¸ row å¯¹åº y è½´ï¼ä»ä¸å°ä¸ï¼ï¼col å¯¹åº x è½´ï¼ä»å·¦å°å³ï¼ï¼
ä½ä»£ç éæè¡ç´¢å¼æ å°å°äº x åæ ï¼åç´¢å¼æ å°å°äº y åæ ã
éè¯¯åæ³ï¼
// â row æ å°å° xï¼col æ å°å° yï¼å¾åä¼æè½¬90度
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
const x = (row - size/2) * cellSize; // éï¼row åºè¯¥æ¯ y
const y = (col - size/2) * cellSize; // éï¼col åºè¯¥æ¯ x
// ...
}
}
æ£ç¡®åæ³ï¼
// â
col æ å°å° xï¼row æ å°å° yï¼ä¸ y è¦ç¿»è½¬ï¼
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
const x = (col - size/2 + 0.5) * cellSize; // col â x
const y = ((size - 1 - row) - size/2 + 0.5) * cellSize; // row â yï¼ç¿»è½¬ï¼
// ...
}
}
è®°å¿å£è¯ï¼
- å¾ååæ ï¼
image[row][col]=image[y][x]ï¼è¡æ¯yï¼åæ¯xï¼ - 3D åæ ï¼x åå³ï¼y åä¸
- 翻转 rowï¼å¾å row=0 å¨é¡¶é¨ï¼3D y=max å¨é¡¶é¨
工使µæä½³å®è·µ
æ¨èç npm scripts é ç½®
{
"scripts": {
"dev": "remotion studio",
"audio": "python3 scripts/generate_audio.py",
"render": "remotion render MyVideo out/video.mp4",
"build": "npm run audio && npm run render"
}
}
宿¶è¿åº¦æ¾ç¤º
é³é¢çæåè§é¢æ¸²æé½å¯è½èæ¶è¾é¿ï¼å¡å¿ 使ç¨åå°æ§è¡ä»¥ä¾¿çå°è¿åº¦ï¼
# â
æ¨èï¼åå°æ§è¡ï¼å®æ¶æ¾ç¤ºè¿åº¦
npm run audio
npm run render
# â
æè
ç¨ shell èæ¬å°è£
bash scripts/render.sh
# â é¿å
ï¼åå°æ§è¡çä¸å°è¿åº¦
npm run render &
render.sh 示ä¾ï¼
#!/bin/bash
cd "$(dirname "$0")/.."
echo "ð¬ å¼å§æ¸²æè§é¢..."
npx remotion render MyVideo out/video.mp4
if [ $? -eq 0 ]; then
echo "â
渲æå®æ!"
ls -lh out/video.mp4
else
echo "â æ¸²æå¤±è´¥"
exit 1
fi
æç¹ç»ä½è®¾è®¡åå
é¿æ¶é´ä»»å¡ï¼å¦æ¹éçæé³é¢ï¼åºæ¯ææç¹ç»ä½ï¼
- æ£æ¥å·²å卿件ï¼è·³è¿å·²å®æç项ç®
- ååæä½ï¼å个æä»¶çæå¤±è´¥ä¸å½±å已宿ç
- è¿åº¦ä¿åï¼å¤±è´¥æ¶ä¿ç已宿çé¨å
- å¹çæ§è¡ï¼éå¤è¿è¡äº§çç¸åç»æ
è°è¯æå·§
- Studio çéè½½ï¼
npm run dev宿¶é¢è§ - æ£æ¥å¸§ï¼Studio 䏿卿¶é´è½´éå¸§æ£æ¥
- æ§è½ï¼é¿å
å¨ç»ä»¶å
åé计ç®ï¼ç¨
useMemo - éææä»¶ï¼æ¾å¨
public/ç®å½ï¼ç¨staticFile()å¼ç¨
常è§é®é¢
Q: è§é¢æ¸²æå¾æ ¢ï¼
- 使ç¨
--concurrencyå¢å å¹¶è¡æ° - éä½åè¾¨çæµè¯ï¼
--scale=0.5 - èè AWS Lambda åå¸å¼æ¸²æ
Q: åä½ä¸æ¾ç¤ºï¼
- 使ç¨
@remotion/google-fontsææ¬å°å è½½ - ç¡®ä¿åä½å¨æ¸²æåå·²å è½½
Q: è§é¢ç´ æä¸ææ¾ï¼
- æ£æ¥è§é¢ç¼ç æ ¼å¼ï¼æ¨è H.264ï¼
- 使ç¨
<OffthreadVideo>æ¿ä»£<Video>æåæ§è½
åèèµæº
- 宿¹ææ¡£ï¼https://remotion.dev/docs
- 模æ¿åºï¼https://remotion.dev/templates
- GitHubï¼https://github.com/remotion-dev/remotion