← back to Pitchvideo
server.js
110 lines
const express = require('express');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const crypto = require('crypto');
const app = express();
const PORT = parseInt(process.env.PORT, 10) || 9774;
const ROOT = __dirname;
const QUEUE_DIR = path.join(ROOT, 'queue');
const OUTPUT_DIR = path.join(ROOT, 'output');
const RENDER_SCRIPT = path.join(process.env.HOME, 'Projects/contact-mailer-ideas/render-pitch-video.sh');
fs.mkdirSync(QUEUE_DIR, { recursive: true });
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
app.use(express.json({ limit: '32kb' }));
app.use(express.urlencoded({ extended: true, limit: '32kb' }));
// 404-guard: refuse to serve accidentally-shipped snapshot files.
app.use((req, res, next) => {
if (/\.(bak(\..+)?|pre-.+|orig|rej|swp)$|~$/.test(req.path)) {
return res.status(404).end();
}
next();
});
app.use(express.static(path.join(ROOT, 'public')));
app.use('/videos', express.static(OUTPUT_DIR));
app.post('/api/order', (req, res) => {
const { name, headline, scalability, email } = req.body || {};
if (!name || !headline || !scalability || !email) {
return res.status(400).json({ error: 'name, headline, scalability, and email are required' });
}
if (!/^[a-zA-Z0-9 ]{2,40}$/.test(name)) {
return res.status(400).json({ error: 'name must be 2-40 alphanumeric characters' });
}
if (headline.length > 120 || scalability.length > 800) {
return res.status(400).json({ error: 'headline ≤120 chars; scalability ≤800 chars' });
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return res.status(400).json({ error: 'invalid email' });
}
const id = crypto.randomBytes(8).toString('hex');
const job = {
id,
name: name.trim(),
headline: headline.trim(),
scalability: scalability.trim(),
email: email.trim().toLowerCase(),
status: 'queued',
created_at: new Date().toISOString()
};
fs.writeFileSync(path.join(QUEUE_DIR, `${id}.json`), JSON.stringify(job, null, 2));
setImmediate(() => renderJob(id));
res.json({ ok: true, id, status_url: `/api/status/${id}` });
});
app.get('/api/status/:id', (req, res) => {
const id = req.params.id.replace(/[^a-f0-9]/g, '');
const p = path.join(QUEUE_DIR, `${id}.json`);
if (!fs.existsSync(p)) return res.status(404).json({ error: 'not found' });
res.json(JSON.parse(fs.readFileSync(p, 'utf8')));
});
function renderJob(id) {
const p = path.join(QUEUE_DIR, `${id}.json`);
if (!fs.existsSync(p)) return;
const job = JSON.parse(fs.readFileSync(p, 'utf8'));
job.status = 'rendering';
job.started_at = new Date().toISOString();
fs.writeFileSync(p, JSON.stringify(job, null, 2));
const script = `${job.headline}. ${job.scalability} This product has venture-scale potential because the underlying mechanic compounds with every customer. We're scaffolding the prototype now and looking for early signal.`;
const proc = spawn('bash', [RENDER_SCRIPT, job.name, '—', job.headline, script], {
env: { ...process.env, HOME: process.env.HOME }
});
let stderr = '';
proc.stderr.on('data', c => { stderr += c.toString(); });
proc.on('close', code => {
job.completed_at = new Date().toISOString();
if (code === 0) {
const slug = job.name.replace(/[^a-zA-Z0-9]/g, '');
const srcMp4 = path.join(process.env.HOME, 'Videos/shipped-apps', `Pitch-${slug}-${new Date().toISOString().slice(0,10)}.mp4`);
if (fs.existsSync(srcMp4)) {
const destMp4 = path.join(OUTPUT_DIR, `${id}.mp4`);
fs.copyFileSync(srcMp4, destMp4);
job.status = 'done';
job.video_url = `/videos/${id}.mp4`;
} else {
job.status = 'error';
job.error = `render succeeded but output MP4 not found at ${srcMp4}`;
}
} else {
job.status = 'error';
job.error = stderr.slice(-500) || `render failed with exit code ${code}`;
}
fs.writeFileSync(p, JSON.stringify(job, null, 2));
});
}
for (const f of fs.readdirSync(QUEUE_DIR).filter(f => f.endsWith('.json'))) {
const job = JSON.parse(fs.readFileSync(path.join(QUEUE_DIR, f), 'utf8'));
if (job.status === 'queued') setImmediate(() => renderJob(job.id));
}
app.listen(PORT, '127.0.0.1', () => console.log(`pitchvideo http://127.0.0.1:${PORT}`));