remotion-studio / server.js
VinOS Agent
Initial commit: Remotion Studio — AI Video Generator HF Space
8a6b85c
const express = require('express');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const cron = require('node-cron');
const app = express();
const PORT = process.env.PORT || 7860;
const API_KEY = process.env.REMOTION_API_KEY || '';
const BUNDLE_DIR = path.join(__dirname, 'bundle');
const STORAGE_DIR = path.join(__dirname, 'storage', 'videos');
const MAX_QUEUE = 5;
const MAX_DURATION_SEC = 60;
// Ensure storage dir exists
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
app.use(express.json({ limit: '10mb' }));
app.use('/storage', express.static(path.join(__dirname, 'storage')));
app.use(express.static(path.join(__dirname, 'public')));
// --- In-memory job store (persisted to disk) ---
const JOBS_FILE = path.join(__dirname, 'storage', 'jobs.json');
let jobs = [];
let isRendering = false;
function loadJobs() {
try {
if (fs.existsSync(JOBS_FILE)) {
jobs = JSON.parse(fs.readFileSync(JOBS_FILE, 'utf8'));
// Reset any stuck "rendering" jobs on restart
jobs.forEach(j => { if (j.status === 'rendering') j.status = 'queued'; });
}
} catch (e) { jobs = []; }
}
function saveJobs() {
try { fs.writeFileSync(JOBS_FILE, JSON.stringify(jobs, null, 2)); } catch (e) { /* */ }
}
loadJobs();
// --- Template Registry ---
const TEMPLATES = {
'text-explainer': {
id: 'TextExplainer',
name: 'Text Explainer',
description: 'Title cards + bullet points with smooth transitions. Great for educational content.',
ratio: '16:9',
width: 1280,
height: 720,
fps: 30,
defaultDuration: 30,
requiredProps: ['title', 'hook', 'bullets'],
optionalProps: ['cta', 'ctaUrl', 'brandColor'],
},
'product-promo': {
id: 'ProductPromo',
name: 'Product Promo',
description: 'Sales offer showcase with price animation + benefits. Perfect for digital products.',
ratio: '16:9',
width: 1280,
height: 720,
fps: 30,
defaultDuration: 10,
requiredProps: ['productName', 'price', 'benefits'],
optionalProps: ['cta', 'ctaUrl', 'brandColor'],
},
'social-short': {
id: 'SocialShort',
name: 'Social Short',
description: 'Vertical format for TikTok/Reels. Hook-first, fast-paced.',
ratio: '9:16',
width: 720,
height: 1280,
fps: 30,
defaultDuration: 15,
requiredProps: ['hook', 'points'],
optionalProps: ['cta', 'brandColor'],
},
'seo-summary': {
id: 'SeoSummary',
name: 'SEO Summary',
description: 'Article summary with key takeaways. Repurpose blog content into video.',
ratio: '16:9',
width: 1280,
height: 720,
fps: 30,
defaultDuration: 20,
requiredProps: ['articleTitle', 'keyPoints'],
optionalProps: ['source', 'cta', 'brandColor'],
},
};
// --- Auth Middleware ---
function authCheck(req, res, next) {
if (!API_KEY) return next(); // No key set = open access
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (token === API_KEY) return next();
res.status(401).json({ error: 'Unauthorized' });
}
// --- API Routes ---
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
templates: Object.keys(TEMPLATES).length,
jobs: { total: jobs.length, rendering: jobs.filter(j => j.status === 'rendering').length, queued: jobs.filter(j => j.status === 'queued').length },
bundleReady: fs.existsSync(BUNDLE_DIR),
});
});
app.get('/api/templates', (req, res) => {
const list = Object.entries(TEMPLATES).map(([key, t]) => ({
key,
name: t.name,
description: t.description,
ratio: t.ratio,
defaultDuration: t.defaultDuration,
requiredProps: t.requiredProps,
optionalProps: t.optionalProps,
}));
res.json({ templates: list });
});
app.post('/api/render', authCheck, (req, res) => {
const { template, inputProps, duration } = req.body;
if (!template || !TEMPLATES[template]) {
return res.status(400).json({ error: `Invalid template. Available: ${Object.keys(TEMPLATES).join(', ')}` });
}
const tmpl = TEMPLATES[template];
// Validate required props
for (const prop of tmpl.requiredProps) {
if (!inputProps || inputProps[prop] === undefined) {
return res.status(400).json({ error: `Missing required prop: ${prop}` });
}
}
// Check queue limit
const pendingCount = jobs.filter(j => j.status === 'queued' || j.status === 'rendering').length;
if (pendingCount >= MAX_QUEUE) {
return res.status(429).json({ error: `Queue full (${MAX_QUEUE} max). Try again later.` });
}
const durationSec = Math.min(duration || tmpl.defaultDuration, MAX_DURATION_SEC);
const durationInFrames = durationSec * tmpl.fps;
const job = {
id: `vid_${Date.now()}_${uuidv4().slice(0, 8)}`,
template,
compositionId: tmpl.id,
inputProps: inputProps || {},
durationInFrames,
width: tmpl.width,
height: tmpl.height,
fps: tmpl.fps,
status: 'queued',
progress: 0,
outputFile: null,
downloadUrl: null,
error: null,
created: new Date().toISOString(),
completed: null,
};
jobs.push(job);
saveJobs();
// Trigger render queue processing
processQueue();
res.json({
jobId: job.id,
status: job.status,
estimatedTime: `${Math.round(durationSec * 2)}s`,
});
});
app.get('/api/jobs', (req, res) => {
const limit = parseInt(req.query.limit) || 20;
const recent = [...jobs].reverse().slice(0, limit).map(sanitizeJob);
res.json({ jobs: recent });
});
app.get('/api/jobs/:id', (req, res) => {
const job = jobs.find(j => j.id === req.params.id);
if (!job) return res.status(404).json({ error: 'Job not found' });
res.json(sanitizeJob(job));
});
app.get('/api/download/:id', (req, res) => {
const job = jobs.find(j => j.id === req.params.id);
if (!job || job.status !== 'complete' || !job.outputFile) {
return res.status(404).json({ error: 'Video not available' });
}
const filePath = path.join(STORAGE_DIR, job.outputFile);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Video file expired or deleted' });
}
res.download(filePath, `${job.id}.mp4`);
});
app.delete('/api/jobs/:id', authCheck, (req, res) => {
const idx = jobs.findIndex(j => j.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: 'Job not found' });
const job = jobs[idx];
// Delete video file if exists
if (job.outputFile) {
const filePath = path.join(STORAGE_DIR, job.outputFile);
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); } catch (e) { /* */ }
}
jobs.splice(idx, 1);
saveJobs();
res.json({ success: true });
});
// Serve web UI
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
function sanitizeJob(job) {
return {
id: job.id,
template: job.template,
status: job.status,
progress: job.progress,
downloadUrl: job.status === 'complete' ? `/api/download/${job.id}` : null,
error: job.error,
created: job.created,
completed: job.completed,
inputProps: job.inputProps,
};
}
// --- Render Queue Processor ---
async function processQueue() {
if (isRendering) return;
const next = jobs.find(j => j.status === 'queued');
if (!next) return;
isRendering = true;
next.status = 'rendering';
next.progress = 5;
saveJobs();
console.log(`[Renderer] Starting: ${next.id} (${next.template})`);
try {
// Lazy-load Remotion renderer (heavy module)
const { renderMedia, selectComposition } = require('@remotion/renderer');
const bundleLocation = BUNDLE_DIR;
if (!fs.existsSync(bundleLocation)) {
throw new Error('Bundle not found. Run: npx remotion bundle src/index.ts --out-dir bundle');
}
next.progress = 10;
saveJobs();
const composition = await selectComposition({
serveUrl: bundleLocation,
id: next.compositionId,
inputProps: next.inputProps,
});
// Override duration
composition.durationInFrames = next.durationInFrames;
composition.width = next.width;
composition.height = next.height;
composition.fps = next.fps;
next.progress = 20;
saveJobs();
const outputFile = `${next.id}.mp4`;
const outputPath = path.join(STORAGE_DIR, outputFile);
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: 'h264',
outputLocation: outputPath,
inputProps: next.inputProps,
chromiumOptions: {
executablePath: process.env.CHROME_PATH || process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
},
concurrency: 1,
imageFormat: 'jpeg',
jpegQuality: 80,
timeoutInMilliseconds: 180000,
onProgress: ({ progress }) => {
next.progress = Math.round(20 + progress * 75);
// Don't save on every frame update — too expensive
if (next.progress % 10 === 0) saveJobs();
},
});
next.status = 'complete';
next.progress = 100;
next.outputFile = outputFile;
next.downloadUrl = `/api/download/${next.id}`;
next.completed = new Date().toISOString();
console.log(`[Renderer] Complete: ${next.id}${outputFile}`);
} catch (err) {
console.error(`[Renderer] Failed: ${next.id}`, err.message);
next.status = 'failed';
next.error = err.message;
}
saveJobs();
isRendering = false;
// Process next in queue
setImmediate(processQueue);
}
// --- Cleanup Cron: delete videos older than 24h ---
cron.schedule('0 * * * *', () => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
let cleaned = 0;
try {
const files = fs.readdirSync(STORAGE_DIR);
for (const file of files) {
if (!file.endsWith('.mp4')) continue;
const filePath = path.join(STORAGE_DIR, file);
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.unlinkSync(filePath);
cleaned++;
}
}
// Also clean job entries for expired videos
jobs = jobs.filter(j => {
if (j.status === 'complete' && j.outputFile) {
const exists = fs.existsSync(path.join(STORAGE_DIR, j.outputFile));
if (!exists) { j.status = 'expired'; return true; }
}
return true;
});
if (cleaned > 0) {
console.log(`[Cleanup] Removed ${cleaned} expired video(s)`);
saveJobs();
}
} catch (e) { /* */ }
});
// --- Boot ---
app.listen(PORT, '0.0.0.0', () => {
console.log(`[Remotion Studio] Running on port ${PORT}`);
console.log(`[Remotion Studio] Bundle: ${fs.existsSync(BUNDLE_DIR) ? 'READY' : 'NOT FOUND — run npm run build'}`);
console.log(`[Remotion Studio] Templates: ${Object.keys(TEMPLATES).join(', ')}`);
console.log(`[Remotion Studio] Auth: ${API_KEY ? 'ENABLED' : 'DISABLED (open access)'}`);
});