This is page 2 of 3. Use http://codebase.md/gyoridavid/short-video-maker?lines=true&page={x} to view the full context. # Directory Structure ``` ├── __mocks__ │ └── pexels-response.json ├── .dockerignore ├── .editorconfig ├── .env.example ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── docker-compose.yml ├── eslint.config.mjs ├── LICENSE ├── main-cuda.Dockerfile ├── main-tiny.Dockerfile ├── main.Dockerfile ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── postcss.config.mjs ├── README.md ├── remotion.config.ts ├── rest.http ├── src │ ├── components │ │ ├── root │ │ │ ├── index.ts │ │ │ └── Root.tsx │ │ ├── types.ts │ │ ├── utils.ts │ │ └── videos │ │ ├── LandscapeVideo.tsx │ │ ├── PortraitVideo.tsx │ │ └── Test.tsx │ ├── config.ts │ ├── index.ts │ ├── logger.ts │ ├── scripts │ │ ├── install.ts │ │ └── normalizeMusic.ts │ ├── server │ │ ├── routers │ │ │ ├── mcp.ts │ │ │ └── rest.ts │ │ ├── server.ts │ │ └── validator.ts │ ├── short-creator │ │ ├── libraries │ │ │ ├── FFmpeg.ts │ │ │ ├── Kokoro.ts │ │ │ ├── Pexels.test.ts │ │ │ ├── Pexels.ts │ │ │ ├── Remotion.ts │ │ │ └── Whisper.ts │ │ ├── music.ts │ │ ├── ShortCreator.test.ts │ │ └── ShortCreator.ts │ ├── types │ │ └── shorts.ts │ └── ui │ ├── App.tsx │ ├── components │ │ └── Layout.tsx │ ├── index.html │ ├── index.tsx │ ├── pages │ │ ├── VideoCreator.tsx │ │ ├── VideoDetails.tsx │ │ └── VideoList.tsx │ ├── public │ │ └── index.html │ └── styles │ └── index.css ├── static │ └── music │ ├── Aurora on the Boulevard - National Sweetheart.mp3 │ ├── Baby Animals Playing - Joel Cummins.mp3 │ ├── Banjo Doops - Joel Cummins.mp3 │ ├── Buckle Up - Jeremy Korpas.mp3 │ ├── Cafecito por la Manana - Cumbia Deli.mp3 │ ├── Champion - Telecasted.mp3 │ ├── Crystaline - Quincas Moreira.mp3 │ ├── Curse of the Witches - Jimena Contreras.mp3 │ ├── Delayed Baggage - Ryan Stasik.mp3 │ ├── Final Soliloquy - Asher Fulero.mp3 │ ├── Heartbeat Of The Wind - Asher Fulero.mp3 │ ├── Honey, I Dismembered The Kids - Ezra Lipp.mp3 │ ├── Hopeful - Nat Keefe.mp3 │ ├── Hopeful Freedom - Asher Fulero.mp3 │ ├── Hopeless - Jimena Contreras.mp3 │ ├── Jetski - Telecasted.mp3 │ ├── Like It Loud - Dyalla.mp3 │ ├── Name The Time And Place - Telecasted.mp3 │ ├── Night Hunt - Jimena Contreras.mp3 │ ├── No.2 Remembering Her - Esther Abrami.mp3 │ ├── Oh Please - Telecasted.mp3 │ ├── On The Hunt - Andrew Langdon.mp3 │ ├── Organic Guitar House - Dyalla.mp3 │ ├── Phantom - Density & Time.mp3 │ ├── README.md │ ├── Restless Heart - Jimena Contreras.mp3 │ ├── Seagull - Telecasted.mp3 │ ├── Sinister - Anno Domini Beats.mp3 │ ├── Sly Sky - Telecasted.mp3 │ ├── Touch - Anno Domini Beats.mp3 │ ├── Traversing - Godmode.mp3 │ └── Twin Engines - Jeremy Korpas.mp3 ├── tailwind.config.js ├── tsconfig.build.json ├── tsconfig.json ├── vite.config.ts └── vitest.config.ts ``` # Files -------------------------------------------------------------------------------- /src/ui/pages/VideoCreator.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import React, { useState, useEffect } from "react"; 2 | import axios from "axios"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { 5 | Box, 6 | Button, 7 | TextField, 8 | Typography, 9 | Paper, 10 | Grid, 11 | FormControl, 12 | InputLabel, 13 | Select, 14 | MenuItem, 15 | CircularProgress, 16 | Alert, 17 | IconButton, 18 | Divider, 19 | InputAdornment, 20 | } from "@mui/material"; 21 | import AddIcon from "@mui/icons-material/Add"; 22 | import DeleteIcon from "@mui/icons-material/Delete"; 23 | import { 24 | SceneInput, 25 | RenderConfig, 26 | MusicMoodEnum, 27 | CaptionPositionEnum, 28 | VoiceEnum, 29 | OrientationEnum, 30 | MusicVolumeEnum, 31 | } from "../../types/shorts"; 32 | 33 | interface SceneFormData { 34 | text: string; 35 | searchTerms: string; // Changed to string 36 | } 37 | 38 | const VideoCreator: React.FC = () => { 39 | const navigate = useNavigate(); 40 | const [scenes, setScenes] = useState<SceneFormData[]>([ 41 | { text: "", searchTerms: "" }, 42 | ]); 43 | const [config, setConfig] = useState<RenderConfig>({ 44 | paddingBack: 1500, 45 | music: MusicMoodEnum.chill, 46 | captionPosition: CaptionPositionEnum.bottom, 47 | captionBackgroundColor: "blue", 48 | voice: VoiceEnum.af_heart, 49 | orientation: OrientationEnum.portrait, 50 | musicVolume: MusicVolumeEnum.high, 51 | }); 52 | 53 | const [loading, setLoading] = useState(false); 54 | const [error, setError] = useState<string | null>(null); 55 | const [voices, setVoices] = useState<VoiceEnum[]>([]); 56 | const [musicTags, setMusicTags] = useState<MusicMoodEnum[]>([]); 57 | const [loadingOptions, setLoadingOptions] = useState(true); 58 | 59 | useEffect(() => { 60 | const fetchOptions = async () => { 61 | try { 62 | const [voicesResponse, musicResponse] = await Promise.all([ 63 | axios.get("/api/voices"), 64 | axios.get("/api/music-tags"), 65 | ]); 66 | 67 | setVoices(voicesResponse.data); 68 | setMusicTags(musicResponse.data); 69 | } catch (err) { 70 | console.error("Failed to fetch options:", err); 71 | setError( 72 | "Failed to load voices and music options. Please refresh the page.", 73 | ); 74 | } finally { 75 | setLoadingOptions(false); 76 | } 77 | }; 78 | 79 | fetchOptions(); 80 | }, []); 81 | 82 | const handleAddScene = () => { 83 | setScenes([...scenes, { text: "", searchTerms: "" }]); 84 | }; 85 | 86 | const handleRemoveScene = (index: number) => { 87 | if (scenes.length > 1) { 88 | const newScenes = [...scenes]; 89 | newScenes.splice(index, 1); 90 | setScenes(newScenes); 91 | } 92 | }; 93 | 94 | const handleSceneChange = ( 95 | index: number, 96 | field: keyof SceneFormData, 97 | value: string, 98 | ) => { 99 | const newScenes = [...scenes]; 100 | newScenes[index] = { ...newScenes[index], [field]: value }; 101 | setScenes(newScenes); 102 | }; 103 | 104 | const handleConfigChange = (field: keyof RenderConfig, value: any) => { 105 | setConfig({ ...config, [field]: value }); 106 | }; 107 | 108 | const handleSubmit = async (e: React.FormEvent) => { 109 | e.preventDefault(); 110 | setLoading(true); 111 | setError(null); 112 | 113 | try { 114 | // Convert scenes to the expected API format 115 | const apiScenes: SceneInput[] = scenes.map((scene) => ({ 116 | text: scene.text, 117 | searchTerms: scene.searchTerms 118 | .split(",") 119 | .map((term) => term.trim()) 120 | .filter((term) => term.length > 0), 121 | })); 122 | 123 | const response = await axios.post("/api/short-video", { 124 | scenes: apiScenes, 125 | config, 126 | }); 127 | 128 | navigate(`/video/${response.data.videoId}`); 129 | } catch (err) { 130 | setError("Failed to create video. Please try again."); 131 | console.error(err); 132 | } finally { 133 | setLoading(false); 134 | } 135 | }; 136 | 137 | if (loadingOptions) { 138 | return ( 139 | <Box 140 | display="flex" 141 | justifyContent="center" 142 | alignItems="center" 143 | height="80vh" 144 | > 145 | <CircularProgress /> 146 | </Box> 147 | ); 148 | } 149 | 150 | return ( 151 | <Box maxWidth="md" mx="auto" py={4}> 152 | <Typography variant="h4" component="h1" gutterBottom> 153 | Create New Video 154 | </Typography> 155 | 156 | {error && ( 157 | <Alert severity="error" sx={{ mb: 3 }}> 158 | {error} 159 | </Alert> 160 | )} 161 | 162 | <form onSubmit={handleSubmit}> 163 | <Typography variant="h5" component="h2" gutterBottom> 164 | Scenes 165 | </Typography> 166 | 167 | {scenes.map((scene, index) => ( 168 | <Paper key={index} sx={{ p: 3, mb: 3 }}> 169 | <Box 170 | display="flex" 171 | justifyContent="space-between" 172 | alignItems="center" 173 | mb={2} 174 | > 175 | <Typography variant="h6">Scene {index + 1}</Typography> 176 | {scenes.length > 1 && ( 177 | <IconButton 178 | onClick={() => handleRemoveScene(index)} 179 | color="error" 180 | size="small" 181 | > 182 | <DeleteIcon /> 183 | </IconButton> 184 | )} 185 | </Box> 186 | 187 | <Grid container spacing={3}> 188 | <Grid item xs={12}> 189 | <TextField 190 | fullWidth 191 | label="Text" 192 | multiline 193 | rows={4} 194 | value={scene.text} 195 | onChange={(e) => 196 | handleSceneChange(index, "text", e.target.value) 197 | } 198 | required 199 | /> 200 | </Grid> 201 | 202 | <Grid item xs={12}> 203 | <TextField 204 | fullWidth 205 | label="Search Terms (comma-separated)" 206 | value={scene.searchTerms} 207 | onChange={(e) => 208 | handleSceneChange(index, "searchTerms", e.target.value) 209 | } 210 | helperText="Enter keywords for background video, separated by commas" 211 | required 212 | /> 213 | </Grid> 214 | </Grid> 215 | </Paper> 216 | ))} 217 | 218 | <Box display="flex" justifyContent="center" mb={4}> 219 | <Button 220 | variant="outlined" 221 | startIcon={<AddIcon />} 222 | onClick={handleAddScene} 223 | > 224 | Add Scene 225 | </Button> 226 | </Box> 227 | 228 | <Divider sx={{ mb: 4 }} /> 229 | 230 | <Typography variant="h5" component="h2" gutterBottom> 231 | Video Configuration 232 | </Typography> 233 | 234 | <Paper sx={{ p: 3, mb: 3 }}> 235 | <Grid container spacing={3}> 236 | <Grid item xs={12} sm={6}> 237 | <TextField 238 | fullWidth 239 | type="number" 240 | label="End Screen Padding (ms)" 241 | value={config.paddingBack} 242 | onChange={(e) => 243 | handleConfigChange("paddingBack", parseInt(e.target.value)) 244 | } 245 | InputProps={{ 246 | endAdornment: ( 247 | <InputAdornment position="end">ms</InputAdornment> 248 | ), 249 | }} 250 | helperText="Duration to keep playing after narration ends" 251 | required 252 | /> 253 | </Grid> 254 | 255 | <Grid item xs={12} sm={6}> 256 | <FormControl fullWidth> 257 | <InputLabel>Music Mood</InputLabel> 258 | <Select 259 | value={config.music} 260 | onChange={(e) => handleConfigChange("music", e.target.value)} 261 | label="Music Mood" 262 | required 263 | > 264 | {Object.values(MusicMoodEnum).map((tag) => ( 265 | <MenuItem key={tag} value={tag}> 266 | {tag} 267 | </MenuItem> 268 | ))} 269 | </Select> 270 | </FormControl> 271 | </Grid> 272 | 273 | <Grid item xs={12} sm={6}> 274 | <FormControl fullWidth> 275 | <InputLabel>Caption Position</InputLabel> 276 | <Select 277 | value={config.captionPosition} 278 | onChange={(e) => 279 | handleConfigChange("captionPosition", e.target.value) 280 | } 281 | label="Caption Position" 282 | required 283 | > 284 | {Object.values(CaptionPositionEnum).map((position) => ( 285 | <MenuItem key={position} value={position}> 286 | {position} 287 | </MenuItem> 288 | ))} 289 | </Select> 290 | </FormControl> 291 | </Grid> 292 | 293 | <Grid item xs={12} sm={6}> 294 | <TextField 295 | fullWidth 296 | label="Caption Background Color" 297 | value={config.captionBackgroundColor} 298 | onChange={(e) => 299 | handleConfigChange("captionBackgroundColor", e.target.value) 300 | } 301 | helperText="Any valid CSS color (name, hex, rgba)" 302 | required 303 | /> 304 | </Grid> 305 | 306 | <Grid item xs={12} sm={6}> 307 | <FormControl fullWidth> 308 | <InputLabel>Default Voice</InputLabel> 309 | <Select 310 | value={config.voice} 311 | onChange={(e) => handleConfigChange("voice", e.target.value)} 312 | label="Default Voice" 313 | required 314 | > 315 | {Object.values(VoiceEnum).map((voice) => ( 316 | <MenuItem key={voice} value={voice}> 317 | {voice} 318 | </MenuItem> 319 | ))} 320 | </Select> 321 | </FormControl> 322 | </Grid> 323 | 324 | <Grid item xs={12} sm={6}> 325 | <FormControl fullWidth> 326 | <InputLabel>Orientation</InputLabel> 327 | <Select 328 | value={config.orientation} 329 | onChange={(e) => 330 | handleConfigChange("orientation", e.target.value) 331 | } 332 | label="Orientation" 333 | required 334 | > 335 | {Object.values(OrientationEnum).map((orientation) => ( 336 | <MenuItem key={orientation} value={orientation}> 337 | {orientation} 338 | </MenuItem> 339 | ))} 340 | </Select> 341 | </FormControl> 342 | </Grid> 343 | 344 | <Grid item xs={12} sm={6}> 345 | <FormControl fullWidth> 346 | <InputLabel>Volume of the background audio</InputLabel> 347 | <Select 348 | value={config.musicVolume} 349 | onChange={(e) => 350 | handleConfigChange("musicVolume", e.target.value) 351 | } 352 | label="Volume of the background audio" 353 | required 354 | > 355 | {Object.values(MusicVolumeEnum).map((voice) => ( 356 | <MenuItem key={voice} value={voice}> 357 | {voice} 358 | </MenuItem> 359 | ))} 360 | </Select> 361 | </FormControl> 362 | </Grid> 363 | </Grid> 364 | </Paper> 365 | 366 | <Box display="flex" justifyContent="center"> 367 | <Button 368 | type="submit" 369 | variant="contained" 370 | color="primary" 371 | size="large" 372 | disabled={loading} 373 | sx={{ minWidth: 200 }} 374 | > 375 | {loading ? ( 376 | <CircularProgress size={24} color="inherit" /> 377 | ) : ( 378 | "Create Video" 379 | )} 380 | </Button> 381 | </Box> 382 | </form> 383 | </Box> 384 | ); 385 | }; 386 | 387 | export default VideoCreator; 388 | ```