File size: 6,596 Bytes
b84a129
 
 
 
 
 
 
 
 
 
 
 
3cd9013
b84a129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cd9013
 
 
 
 
 
b84a129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3cd9013
b84a129
 
 
3cd9013
b84a129
 
 
 
 
 
3cd9013
 
 
 
 
b84a129
 
 
 
 
 
 
 
 
3cd9013
 
 
b84a129
 
 
 
 
 
3cd9013
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b84a129
 
 
 
 
 
 
3cd9013
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
import express from 'express';
import fileUpload from 'express-fileupload';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { v4 as uuid } from 'uuid';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { renderVideo } from './lib/renderer.js';
import { buildComposition } from './lib/composer.js';
import { getMusicTrack } from './lib/music.js';
import { uploadToHuggingFace } from './lib/uploader.js';
import { WORKFLOWS } from './workflows/index.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });

const jobs = new Map();
const clients = new Map();

app.use(cors());
app.use(express.json());
app.use(fileUpload({ limits: { fileSize: 500 * 1024 * 1024 }, useTempFiles: true, tempFileDir: '/tmp/' }));
app.use('/static', express.static(path.join(__dirname, 'static')));
app.use('/renders', express.static(path.join(__dirname, 'renders')));
app.use('/', express.static(path.join(__dirname, 'static')));

wss.on('connection', (ws, req) => {
  const jobId = new URL(req.url, 'http://x').searchParams.get('job');
  if (jobId) clients.set(jobId, ws);
  ws.on('close', () => { for (const [id, c] of clients) if (c === ws) clients.delete(id); });
});

function push(jobId, data) {
  const ws = clients.get(jobId);
  if (ws?.readyState === 1) ws.send(JSON.stringify(data));
  const job = jobs.get(jobId);
  if (job) Object.assign(job, data);
}

app.get('/api/health', (_, res) => res.json({
  status: 'ok',
  time: new Date().toISOString(),
  hf_renders_repo: process.env.HF_RENDERS_REPO || 'AIgoose/video-renders',
  hf_token_set: !!process.env.HF_TOKEN,
}));

app.get('/api/workflows', (_, res) => res.json(
  Object.entries(WORKFLOWS).map(([id, w]) => ({ id, ...w.meta }))
));

app.get('/api/music', (_, res) => {
  const lib = path.join(__dirname, 'music/library');
  const tracks = fs.existsSync(lib)
    ? fs.readdirSync(lib).filter(f => f.endsWith('.mp3') || f.endsWith('.wav'))
    : [];
  const meta = tracks.map(f => {
    const metaFile = path.join(lib, f.replace(/\.(mp3|wav)$/, '.json'));
    const m = fs.existsSync(metaFile) ? JSON.parse(fs.readFileSync(metaFile, 'utf8')) : {};
    return { file: f, ...m };
  });
  res.json(meta);
});

app.get('/api/jobs', (_, res) =>
  res.json([...jobs.values()].sort((a, b) => b.createdAt - a.createdAt))
);

app.get('/api/jobs/:id', (req, res) => {
  const job = jobs.get(req.params.id);
  if (!job) return res.status(404).json({ error: 'Not found' });
  res.json(job);
});

app.post('/api/render', async (req, res) => {
  const jobId = uuid();
  const config = {
    workflow: req.body.workflow || 'testimonial',
    projectName: req.body.projectName || req.body.clientName || 'untitled',
    clientName: req.body.clientName || '',
    trainerName: req.body.trainerName || 'Dee Ferdinand',
    tagline: req.body.tagline || 'AI Corporate Trainer',
    website: req.body.website || 'deeferdinand.com',
    musicTrack: req.body.musicTrack || 'auto',
    musicVolume: parseFloat(req.body.musicVolume || '0.3'),
    format: req.body.format || '9:16',
    duration: parseInt(req.body.duration || '75'),
  };

  const job = { id: jobId, status: 'queued', progress: 0, ...config, createdAt: Date.now(), files: [] };
  jobs.set(jobId, job);

  const projectDir = path.join(__dirname, 'uploads', jobId);
  fs.mkdirSync(projectDir, { recursive: true });

  const uploadedFiles = req.files ? Object.values(req.files).flat() : [];
  for (const f of uploadedFiles) {
    const dest = path.join(projectDir, f.name);
    await f.mv(dest);
    job.files.push({ name: f.name, path: dest, mime: f.mimetype, size: f.size });
  }
  config._files = job.files;

  res.json({ jobId, status: 'queued' });
  setImmediate(() => runJob(jobId, projectDir, config));
});

// Legacy local download (fallback if HF upload fails)
app.get('/api/download/:jobId', (req, res) => {
  const job = jobs.get(req.params.jobId);
  if (!job?.outputPath || !fs.existsSync(job.outputPath))
    return res.status(404).json({ error: 'Not ready or already uploaded to HF' });
  res.download(job.outputPath);
});

async function runJob(jobId, projectDir, config) {
  try {
    push(jobId, { status: 'composing', progress: 5 });

    const musicPath = await getMusicTrack(
      config.musicTrack, config.workflow, config.duration,
      (d) => push(jobId, d)
    );
    push(jobId, { progress: 15 });

    const compDir = path.join(__dirname, 'compositions', 'projects', jobId);
    fs.mkdirSync(compDir, { recursive: true });

    const workflow = WORKFLOWS[config.workflow];
    if (!workflow) throw new Error(`Unknown workflow: ${config.workflow}`);

    await buildComposition(compDir, projectDir, musicPath, config, workflow,
      (p) => push(jobId, { status: 'composing', progress: 15 + Math.round(p * 0.35) }));

    push(jobId, { status: 'rendering', progress: 50 });

    const outDir = path.join(__dirname, 'renders');
    fs.mkdirSync(outDir, { recursive: true });
    const outputFile = path.join(outDir, `${jobId}.mp4`);

    await renderVideo(compDir, outputFile, config,
      (p) => push(jobId, { status: 'rendering', progress: 50 + Math.round(p * 0.35) }));

    push(jobId, { status: 'uploading', progress: 88 });

    // Build a human-readable filename
    const slug = (config.clientName || config.projectName || 'video')
      .toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
    const ts = new Date().toISOString().slice(0, 10);
    const hfFilename = `${slug}-${config.workflow}-${ts}-${jobId.slice(0, 8)}.mp4`;

    let hfResult = null;
    try {
      hfResult = await uploadToHuggingFace(outputFile, hfFilename);
      // Clean up local render to save Space disk
      fs.unlinkSync(outputFile);
    } catch (uploadErr) {
      console.warn('[upload warning] HF upload failed, keeping local file:', uploadErr.message);
    }

    push(jobId, {
      status: 'done',
      progress: 100,
      outputPath: hfResult ? null : outputFile,
      hf_url: hfResult?.hf_url || null,
      viewer_url: hfResult?.viewer_url || null,
      hf_filename: hfResult?.filename || null,
      repo_id: hfResult?.repo_id || null,
      outputUrl: hfResult ? hfResult.hf_url : `/renders/${jobId}.mp4`,
    });
  } catch (err) {
    console.error('[job error]', jobId, err);
    push(jobId, { status: 'error', error: err.message });
  }
}

const PORT = process.env.PORT || 7860;
server.listen(PORT, () => console.log(`\u2705 Dee Video Studio on port ${PORT}`));