← 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}`));