import express, { NextFunction, Request, Response } from 'express'; import * as fs from 'fs'; import * as path from 'path'; import { config } from 'dotenv'; import { generateVideo } from './video-generator'; import { renderVideo } from './render'; import { ensureProjectRootCwd, jobStore, resolveProjectPath } from './runtime'; import type { JobStatus } from './runtime'; ensureProjectRootCwd(); config({ path: resolveProjectPath('.env') }); const app = express(); app.set('trust proxy', true); const PORT = Number(process.env.PORT || 3001); const OUTPUT_ROOT = resolveProjectPath('output'); const DEFAULT_TITLE = 'Generated Video'; const DEFAULT_VOICE = 'en-US-JennyNeural'; const DEFAULT_FALLBACK_VIDEO = 'default.mp4'; const MAX_TITLE_LENGTH = 80; const PROJECT_NAME = 'Automated Video Generator'; const PROJECT_REPOSITORY_URL = 'https://github.com/itsPremkumar/Automated-Video-Generator'; const PROJECT_LICENSE_URL = 'https://opensource.org/licenses/MIT'; const DEFAULT_SITE_DESCRIPTION = 'Free and open-source AI text-to-video generator built with Remotion, Edge-TTS, stock footage APIs, and a local web portal for YouTube Shorts, TikTok videos, explainers, and marketing content.'; const DEFAULT_SITE_KEYWORDS = 'free video generator, open-source video generator, ai video generator, text to video, remotion video generator, self-hosted video generator, youtube shorts generator, tiktok video generator, mcp video automation'; const BRAND_COLOR = '#d8642a'; type Orientation = 'portrait' | 'landscape'; interface VideoRecord { id: string; title: string; createdAt: string; orientation: string; durationSeconds: number | null; description: string | null; fileSizeMB: string; videoFilename: string; videoPath: string; thumbnailPath: string | null; watchUrl: string; downloadUrl: string; videoUrl: string; thumbnailUrl: string | null; } const rateLimitMap = new Map(); const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; const RATE_LIMIT_MAX = 10; function rateLimiter(req: Request, res: Response, next: NextFunction) { const ip = req.ip || req.socket.remoteAddress || 'unknown'; const now = Date.now(); const record = rateLimitMap.get(ip); if (!record || now > record.resetTime) { rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS }); next(); return; } if (record.count >= RATE_LIMIT_MAX) { const retryAfter = Math.ceil((record.resetTime - now) / 1000); res.set('Retry-After', String(retryAfter)); res.status(429).json({ success: false, error: 'Too many requests', retryAfter }); return; } record.count += 1; next(); } function validateScript(script: unknown): { valid: boolean; error?: string } { if (!script || typeof script !== 'string') { return { valid: false, error: 'Script is required and must be a string.' }; } const trimmed = script.trim(); if (trimmed.length < 10) { return { valid: false, error: 'Script is too short. Minimum 10 characters.' }; } if (trimmed.length > 5000) { return { valid: false, error: 'Script is too long. Maximum 5000 characters.' }; } return { valid: true }; } function validateTitle(title: unknown): { valid: boolean; error?: string } { if (title === undefined || title === null || title === '') { return { valid: true }; } if (typeof title !== 'string') { return { valid: false, error: 'Title must be a string.' }; } if (title.trim().length === 0) { return { valid: false, error: 'Title cannot be empty.' }; } if (title.trim().length > MAX_TITLE_LENGTH) { return { valid: false, error: `Title is too long. Maximum ${MAX_TITLE_LENGTH} characters.` }; } return { valid: true }; } function normalizeTitle(value: unknown): string { if (typeof value !== 'string' || value.trim().length === 0) { return DEFAULT_TITLE; } return value.trim().slice(0, MAX_TITLE_LENGTH); } function normalizeString(value: unknown, fallback: string): string { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback; } function normalizeOrientation(value: unknown): Orientation { return value === 'landscape' ? 'landscape' : 'portrait'; } function normalizeBoolean(value: unknown, fallback: boolean): boolean { return typeof value === 'boolean' ? value : fallback; } function sanitizeFolderTitle(value: string): string { return value .replace(/[^a-zA-Z0-9 _-]/g, '') .trim() .replace(/\s+/g, '_') .replace(/_+/g, '_') .slice(0, 50); } function baseUrl(req: Request): string { const configured = process.env.PUBLIC_BASE_URL?.trim(); if (configured) { return configured.replace(/\/+$/, ''); } return `${req.protocol}://${req.get('host') || `localhost:${PORT}`}`; } function absoluteUrl(req: Request, pathname: string): string { return `${baseUrl(req)}${pathname}`; } function safePublicId(publicId: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(publicId); } function outputFolder(publicId: string): string | null { if (!safePublicId(publicId)) { return null; } const folder = path.join(OUTPUT_ROOT, publicId); if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) { return null; } return folder; } function findVideoFile(folder: string): string | null { const files = fs.readdirSync(folder).filter((name) => name.toLowerCase().endsWith('.mp4') && !name.startsWith('segment')); return files[0] || null; } function readSceneData(folder: string): { orientation: string; durationSeconds: number | null } { const file = path.join(folder, 'scene-data.json'); if (!fs.existsSync(file)) { return { orientation: 'unknown', durationSeconds: null }; } try { const data = JSON.parse(fs.readFileSync(file, 'utf8')); return { orientation: data.orientation === 'landscape' ? 'landscape' : data.orientation === 'portrait' ? 'portrait' : 'unknown', durationSeconds: typeof data.totalDuration === 'number' ? data.totalDuration : null, }; } catch { return { orientation: 'unknown', durationSeconds: null }; } } function readDescription(folder: string): string | null { const file = fs.readdirSync(folder).find((name) => name.toLowerCase().endsWith('.txt')); if (!file) { return null; } const text = fs.readFileSync(path.join(folder, file), 'utf8').trim(); return text.length > 0 ? text : null; } function getVideo(publicId: string, req: Request): VideoRecord | null { const folder = outputFolder(publicId); if (!folder) { return null; } const videoFilename = findVideoFile(folder); if (!videoFilename) { return null; } const videoPath = path.join(folder, videoFilename); const stats = fs.statSync(videoPath); const thumbnailPath = path.join(folder, 'thumbnail.jpg'); const scene = readSceneData(folder); return { id: publicId, title: path.basename(videoFilename, path.extname(videoFilename)).replace(/_/g, ' '), createdAt: new Date(stats.mtimeMs).toISOString(), orientation: scene.orientation, durationSeconds: scene.durationSeconds, description: readDescription(folder), fileSizeMB: (stats.size / (1024 * 1024)).toFixed(2), videoFilename, videoPath, thumbnailPath: fs.existsSync(thumbnailPath) ? thumbnailPath : null, watchUrl: absoluteUrl(req, `/videos/${encodeURIComponent(publicId)}`), downloadUrl: absoluteUrl(req, `/download/${encodeURIComponent(publicId)}`), videoUrl: absoluteUrl(req, `/files/${encodeURIComponent(publicId)}/video`), thumbnailUrl: fs.existsSync(thumbnailPath) ? absoluteUrl(req, `/files/${encodeURIComponent(publicId)}/thumbnail`) : null, }; } function listVideos(req: Request): VideoRecord[] { if (!fs.existsSync(OUTPUT_ROOT)) { return []; } return fs.readdirSync(OUTPUT_ROOT) .map((name) => getVideo(name, req)) .filter((video): video is VideoRecord => Boolean(video)) .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()); } function publicVideo(video: VideoRecord) { return { id: video.id, title: video.title, createdAt: video.createdAt, orientation: video.orientation, durationSeconds: video.durationSeconds, description: video.description, fileSizeMB: video.fileSizeMB, watchUrl: video.watchUrl, downloadUrl: video.downloadUrl, videoUrl: video.videoUrl, thumbnailUrl: video.thumbnailUrl, }; } function jobData(job: JobStatus, req: Request) { const publicId = job.publicId || (job.outputPath ? path.basename(path.dirname(job.outputPath)) : null); const data: Record = { jobId: job.id, title: job.title || null, publicId, status: job.status, progress: job.progress, message: job.message, error: job.error || null, startedAt: new Date(job.startTime).toISOString(), finishedAt: job.endTime ? new Date(job.endTime).toISOString() : null, statusUrl: absoluteUrl(req, `/api/jobs/${encodeURIComponent(job.id)}`), statusPageUrl: absoluteUrl(req, `/jobs/${encodeURIComponent(job.id)}`), }; if (publicId) { data.watchUrl = absoluteUrl(req, `/videos/${encodeURIComponent(publicId)}`); data.downloadUrl = absoluteUrl(req, `/download/${encodeURIComponent(publicId)}`); } if (publicId && job.status === 'completed') { const video = getVideo(publicId, req); if (video) { data.video = publicVideo(video); data.watchUrl = video.watchUrl; data.downloadUrl = video.downloadUrl; data.videoUrl = video.videoUrl; } } return data; } function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } interface HtmlOptions { canonical?: string; description?: string; imageUrl?: string | null; jsonLd?: Record | Array>; keywords?: string; ogType?: string; robots?: string; } function normalizeMetaText(value: string): string { return value.replace(/\s+/g, ' ').trim(); } function truncateText(value: string, maxLength: number): string { const normalized = normalizeMetaText(value); if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; } function toIsoDuration(durationSeconds: number | null): string | undefined { if (!durationSeconds || durationSeconds <= 0) { return undefined; } return `PT${Math.max(1, Math.round(durationSeconds))}S`; } function serializeJsonLd(jsonLd?: Record | Array>): string { if (!jsonLd) { return ''; } const items = Array.isArray(jsonLd) ? jsonLd : [jsonLd]; return items .map((item) => ``) .join(''); } function videoMetaDescription(video: VideoRecord): string { const fallback = `${video.title} is a video published with ${PROJECT_NAME}, a free and open-source Remotion-based text-to-video generator.`; return truncateText(video.description || fallback, 160); } function xmlEscape(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function noIndex(res: Response): void { res.set('X-Robots-Tag', 'noindex, nofollow, noarchive'); } function socialPreviewSvg(): string { return ` COMPLETELY FREE ${xmlEscape(PROJECT_NAME)} Open-source AI text-to-video with Remotion, Edge-TTS, stock visuals, a local portal, and MCP automation. No watermark Self-hosted and MIT licensed MP4 Remotion Edge-TTS github.com/itsPremkumar/Automated-Video-Generator `; } function sitemapXml(req: Request): string { const videos = listVideos(req); const items = [ { changefreq: 'daily', lastmod: videos[0]?.createdAt || new Date().toISOString(), loc: absoluteUrl(req, '/'), priority: '1.0', }, ...videos.map((video) => ({ changefreq: 'weekly', lastmod: video.createdAt, loc: video.watchUrl, priority: '0.8', })), ]; const urls = items .map((item) => `${xmlEscape(item.loc)}${xmlEscape(item.lastmod)}${item.changefreq}${item.priority}`) .join(''); return `${urls}`; } function html(title: string, body: string, options: HtmlOptions = {}, script = ''): string { const description = options.description || DEFAULT_SITE_DESCRIPTION; const keywords = options.keywords || DEFAULT_SITE_KEYWORDS; const robots = options.robots || 'index,follow,max-image-preview:large'; const ogType = options.ogType || 'website'; const canonical = options.canonical ? `` : ''; const ogUrl = options.canonical ? `` : ''; const imageMeta = options.imageUrl ? `` : ''; const twitterCard = options.imageUrl ? 'summary_large_image' : 'summary'; const jsonLd = serializeJsonLd(options.jsonLd); return `${escapeHtml(title)}${ogUrl}${imageMeta}${canonical}${jsonLd}
${body}
${script ? `` : ''}`; } function homePage(req: Request, videos: VideoRecord[]): string { const defaultOgImage = absoluteUrl(req, '/og-image.svg'); const cards = videos.length > 0 ? videos.map((video) => `

${escapeHtml(video.title)}

${escapeHtml(video.orientation)} - ${video.fileSizeMB} MB

${video.durationSeconds ? `${Math.round(video.durationSeconds)} sec` : ''}${escapeHtml(new Date(video.createdAt).toLocaleString())}
`).join('') : '

No completed videos yet. Start one below and it will appear here automatically.

'; return html( `Free Automated Video Generator | Open-Source Remotion Text-to-Video Tool`, `

${PROJECT_NAME}

Free and open-source AI text-to-video generator for creating YouTube Shorts, TikTok videos, explainers, and marketing content with Remotion, Edge-TTS, and stock media APIs.

Start a render, share the status page, then send the final watch or download link to the end user.

Completely free and open source

This project is MIT-licensed and free to use. There is no repo-owned paid tier and no watermark added by this codebase. If you use optional third-party providers such as Pexels or Pixabay, their own limits still apply.

Why creators use it

YouTube Shorts and TikTok

Generate portrait videos from scripts with narration, stock visuals, and local assets.

Self-hosted pipeline

Run locally with your own APIs, assets, and workflow control instead of relying on closed platforms.

MCP-ready automation

Let Claude Desktop, Claude Code, or other MCP clients generate and manage videos through tools.

Create a video

You will be redirected to a live job page.

Completed videos

${cards}
`, { canonical: absoluteUrl(req, '/'), description: DEFAULT_SITE_DESCRIPTION, imageUrl: videos[0]?.thumbnailUrl || defaultOgImage, jsonLd: [ { '@context': 'https://schema.org', '@type': 'SoftwareApplication', applicationCategory: 'MultimediaApplication', description: DEFAULT_SITE_DESCRIPTION, isAccessibleForFree: true, name: PROJECT_NAME, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD', }, operatingSystem: 'Windows, macOS, Linux', sameAs: PROJECT_REPOSITORY_URL, url: absoluteUrl(req, '/'), }, { '@context': 'https://schema.org', '@type': 'SoftwareSourceCode', codeRepository: PROJECT_REPOSITORY_URL, description: DEFAULT_SITE_DESCRIPTION, license: PROJECT_LICENSE_URL, name: PROJECT_NAME, programmingLanguage: ['TypeScript', 'React'], runtimePlatform: 'Node.js', }, ], keywords: DEFAULT_SITE_KEYWORDS, ogType: 'website', }, `const form=document.getElementById('generate-form');const status=document.getElementById('form-status');form.addEventListener('submit',async(e)=>{e.preventDefault();status.hidden=false;status.textContent='Starting render...';const payload={title:document.getElementById('title').value,script:document.getElementById('script').value,orientation:document.getElementById('orientation').value,voice:document.getElementById('voice').value,defaultVideo:document.getElementById('defaultVideo').value,showText:document.getElementById('showText').checked};try{const res=await fetch('/generate-video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});const json=await res.json();if(!res.ok||!json.success)throw new Error(json.error||'Unable to start render.');window.location.href=json.data.statusPageUrl;}catch(err){status.textContent=err instanceof Error?err.message:'Unable to start render.';}});`, ); } function jobPage(req: Request, jobId: string): string { return html( `Render Job ${jobId} | ${PROJECT_NAME}`, `

Render in progress

This page refreshes automatically until the video is ready.

Status

pending

Progress

0%

Job ID

${escapeHtml(jobId)}

`, { canonical: absoluteUrl(req, `/jobs/${encodeURIComponent(jobId)}`), description: 'Track a video rendering job in Automated Video Generator.', ogType: 'website', robots: 'noindex, nofollow', }, `const id=${JSON.stringify(jobId)};const title=document.getElementById('title');const message=document.getElementById('message');const status=document.getElementById('status');const percent=document.getElementById('percent');const progress=document.getElementById('progress');const actions=document.getElementById('actions');const error=document.getElementById('error');async function refresh(){try{const res=await fetch('/api/jobs/'+encodeURIComponent(id),{cache:'no-store'});const json=await res.json();if(!res.ok||!json.success)throw new Error(json.error||'Unable to load job.');const data=json.data;title.textContent=data.title||'Render in progress';message.textContent=data.message||'Working on your video.';status.textContent=String(data.status);percent.textContent=String(data.progress)+'%';progress.style.width=Math.max(0,Math.min(100,Number(data.progress)||0))+'%';if(data.status==='completed'){actions.innerHTML='Open Watch PageDownload MP4Back to Portal';window.clearInterval(timer);}if(data.status==='failed'){error.hidden=false;error.textContent=data.error||'Render failed.';window.clearInterval(timer);}}catch(err){error.hidden=false;error.textContent=err instanceof Error?err.message:'Unable to load job.';}}const timer=window.setInterval(refresh,3000);refresh();`, ); } function watchPage(req: Request, video: VideoRecord): string { const description = videoMetaDescription(video); return html( `${video.title} | ${PROJECT_NAME}`, `

${escapeHtml(video.title)}

Stream the video here or download the MP4. This delivery page is generated by ${PROJECT_NAME}.

${escapeHtml(video.orientation)}${video.durationSeconds ? `${Math.round(video.durationSeconds)} sec` : ''}${video.fileSizeMB} MB
${video.description ? `

Video details

${escapeHtml(video.description).replace(/\n/g, '
')}

` : ''}

Built with ${PROJECT_NAME}

`, { canonical: video.watchUrl, description, imageUrl: video.thumbnailUrl || absoluteUrl(req, '/og-image.svg'), jsonLd: [ { '@context': 'https://schema.org', '@type': 'VideoObject', contentUrl: video.videoUrl, description, duration: toIsoDuration(video.durationSeconds), embedUrl: video.watchUrl, isAccessibleForFree: true, name: video.title, thumbnailUrl: video.thumbnailUrl ? [video.thumbnailUrl] : undefined, uploadDate: video.createdAt, url: video.watchUrl, }, { '@context': 'https://schema.org', '@type': 'SoftwareApplication', applicationCategory: 'MultimediaApplication', description: DEFAULT_SITE_DESCRIPTION, isAccessibleForFree: true, name: PROJECT_NAME, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD', }, sameAs: PROJECT_REPOSITORY_URL, url: absoluteUrl(req, '/'), }, ], keywords: DEFAULT_SITE_KEYWORDS, ogType: 'video.other', }, ); } async function startJob(req: Request, res: Response) { const titleCheck = validateTitle(req.body?.title); if (!titleCheck.valid) { res.status(400).json({ success: false, error: titleCheck.error }); return; } const scriptCheck = validateScript(req.body?.script); if (!scriptCheck.valid) { res.status(400).json({ success: false, error: scriptCheck.error }); return; } const title = normalizeTitle(req.body?.title); const script = normalizeString(req.body?.script, '').trim(); const orientation = normalizeOrientation(req.body?.orientation); const voice = normalizeString(req.body?.voice, DEFAULT_VOICE); const showText = normalizeBoolean(req.body?.showText, true); const defaultVideo = normalizeString(req.body?.defaultVideo, DEFAULT_FALLBACK_VIDEO); const publicId = `${sanitizeFolderTitle(title) || 'video'}_${Date.now()}`; const jobId = `job_${Date.now()}_${title.replace(/[^a-zA-Z0-9]/g, '').slice(0, 10) || 'video'}`; const outputDir = resolveProjectPath('output', publicId); fs.mkdirSync(outputDir, { recursive: true }); jobStore.set(jobId, { title, publicId, status: 'pending', progress: 0, message: 'Queued for processing.', }); void (async () => { try { jobStore.set(jobId, { status: 'processing', progress: 5, message: 'Generating assets and voiceover.' }); const result = await generateVideo(script, outputDir, { title, orientation, voice, showText, defaultVideo, onProgress: (step: string, percent: number, message: string) => { jobStore.set(jobId, { status: 'processing', progress: 5 + Math.round((percent / 100) * 60), message: `${step}: ${message}`, }); }, }); if (!result.success) { jobStore.set(jobId, { status: 'failed', progress: 100, message: 'Generation failed before render.', error: result.error || 'Unknown generation error.', endTime: Date.now(), }); return; } jobStore.set(jobId, { status: 'processing', progress: 75, message: 'Rendering final MP4.' }); await renderVideo(outputDir); const finalVideo = findVideoFile(outputDir); if (!finalVideo) { jobStore.set(jobId, { status: 'failed', progress: 100, message: 'Render finished without a final MP4.', error: 'No final video file found.', endTime: Date.now(), }); return; } jobStore.set(jobId, { status: 'completed', progress: 100, message: 'Video ready for playback and download.', outputPath: path.join(outputDir, finalVideo), endTime: Date.now(), }); } catch (error: any) { jobStore.set(jobId, { status: 'failed', progress: 100, message: 'A fatal error occurred while processing the job.', error: error?.message || 'Unknown server error.', endTime: Date.now(), }); } })(); res.status(202).json({ success: true, data: { jobId, title, publicId, statusUrl: absoluteUrl(req, `/api/jobs/${encodeURIComponent(jobId)}`), statusPageUrl: absoluteUrl(req, `/jobs/${encodeURIComponent(jobId)}`), }, }); } app.use((req: Request, res: Response, next: NextFunction) => { res.set('X-Content-Type-Options', 'nosniff'); res.set('X-Frame-Options', 'DENY'); res.set('X-XSS-Protection', '1; mode=block'); next(); }); app.use((req: Request, res: Response, next: NextFunction) => { res.set('Access-Control-Allow-Origin', '*'); res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.set('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.status(204).end(); return; } next(); }); app.use(express.json({ limit: '10kb' })); app.use((req: Request, res: Response, next: NextFunction) => { if ( req.path === '/generate-video' || req.path === '/health' || req.path.startsWith('/api/') || req.path.startsWith('/download/') || req.path.startsWith('/files/') || req.path.startsWith('/jobs/') ) { noIndex(res); } next(); }); app.get('/robots.txt', (req: Request, res: Response) => { res.type('text/plain').send( `User-agent: *\nAllow: /\nDisallow: /api/\nDisallow: /download/\nDisallow: /files/\nDisallow: /health\nDisallow: /jobs/\nSitemap: ${absoluteUrl(req, '/sitemap.xml')}\n` ); }); app.get('/sitemap.xml', (req: Request, res: Response) => { res.type('application/xml').send(sitemapXml(req)); }); app.get('/og-image.svg', (req: Request, res: Response) => { res.type('image/svg+xml').send(socialPreviewSvg()); }); app.get('/llms.txt', (req: Request, res: Response) => { res.type('text/plain').sendFile(resolveProjectPath('llms.txt')); }); app.get('/llms-full.txt', (req: Request, res: Response) => { res.type('text/plain').sendFile(resolveProjectPath('llms-full.txt')); }); app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', service: 'video-generator', publishedVideos: listVideos(req).length, jobsTracked: jobStore.all().length }); }); app.get('/api/videos', (req: Request, res: Response) => { res.json({ success: true, data: listVideos(req).map(publicVideo) }); }); app.get('/api/videos/:videoId', (req: Request, res: Response) => { const videoId = String(req.params.videoId); const video = getVideo(videoId, req); if (!video) { res.status(404).json({ success: false, error: 'Video not found.' }); return; } res.json({ success: true, data: publicVideo(video) }); }); app.get('/api/jobs/:jobId', (req: Request, res: Response) => { const jobId = String(req.params.jobId); const job = jobStore.get(jobId); if (!job) { res.status(404).json({ success: false, error: 'Job not found.' }); return; } res.json({ success: true, data: jobData(job, req) }); }); app.post('/generate-video', rateLimiter, startJob); app.post('/api/jobs', rateLimiter, startJob); app.get('/files/:videoId/video', (req: Request, res: Response) => { const videoId = String(req.params.videoId); const video = getVideo(videoId, req); if (!video) { res.status(404).send('Video not found.'); return; } res.type('video/mp4').sendFile(video.videoPath); }); app.get('/files/:videoId/thumbnail', (req: Request, res: Response) => { const videoId = String(req.params.videoId); const video = getVideo(videoId, req); if (!video || !video.thumbnailPath) { res.status(404).send('Thumbnail not found.'); return; } res.sendFile(video.thumbnailPath); }); app.get('/download/:videoId', (req: Request, res: Response) => { const videoId = String(req.params.videoId); const video = getVideo(videoId, req); if (!video) { res.status(404).send('Video not found.'); return; } res.download(video.videoPath, video.videoFilename); }); app.get('/videos/:videoId', (req: Request, res: Response) => { const videoId = String(req.params.videoId); const video = getVideo(videoId, req); if (!video) { res.status(404).type('html').send( html( `Video Not Found | ${PROJECT_NAME}`, '

Video not found

The requested video is not available.

Back to Portal
', { description: 'The requested video page could not be found.', ogType: 'website', robots: 'noindex, nofollow', }, ) ); return; } res.type('html').send(watchPage(req, video)); }); app.get('/jobs/:jobId', (req: Request, res: Response) => { const jobId = String(req.params.jobId); res.type('html').send(jobPage(req, jobId)); }); app.get('/', (req: Request, res: Response) => { res.type('html').send(homePage(req, listVideos(req))); }); app.use((err: Error, req: Request, res: Response, next: NextFunction) => { res.status(500).json({ success: false, error: 'Internal server error' }); }); app.listen(PORT, () => { console.log(`Video Generator portal running on http://localhost:${PORT}`); });