VinOS Agent Claude Opus 4.6 commited on
Commit
1d15a1f
Β·
1 Parent(s): 6144942

Phase 8: Video Engine (Remotion Studio HF Space + /videos command)

Browse files

Separate Remotion-powered HF Space (AIgoose/remotion-studio) with 4 video
templates, Express API, and web UI. VinOS integration via video_engine.js
skill, /videos Telegram command, sendTelegramVideo, proxy dashboard routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
 
3
  ---
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  ## Phase 0 + 6 + 7 β€” Quick Wins, SEO-to-Revenue, Self-Optimization
6
  **Date:** 2026-03-27
7
 
 
2
 
3
  ---
4
 
5
+ ## Phase 8 β€” Video Engine (Remotion Studio + /videos command)
6
+ **Date:** 2026-03-27
7
+
8
+ ### New HF Space: `AIgoose/remotion-studio`
9
+ - Separate Remotion-powered video rendering API + web UI
10
+ - 4 video templates: TextExplainer (16:9), ProductPromo (16:9), SocialShort (9:16), SeoSummary (16:9)
11
+ - Docker: Node 20 + Chromium + pre-bundled compositions
12
+ - REST API: `/api/render`, `/api/jobs`, `/api/download/:id`, `/api/templates`
13
+ - Async render queue with progress tracking + 24h auto-cleanup
14
+ - Dark theme web UI with template gallery, create form, job queue, video player
15
+
16
+ ### VinOS Integration
17
+ - **New files:** `skills/video_engine.js`, `public/videos-dashboard.html`, `database/video_db.json`
18
+ - **Modified:** `server.js`, `skills/api_caller.js`, `skills/set_telegram_menu.js`, `.env`
19
+ - **New Telegram command:** `/videos` with sub-commands: `create`, `templates`, `status`
20
+ - **New API method:** `sendTelegramVideo` in api_caller.js (50MB limit)
21
+ - **Proxy routes:** `/videos-dashboard`, `/api/videos/templates`, `/api/videos/create`, `/api/videos/jobs`, `/api/videos/download/:id`
22
+ - **Pipeline:** Topic β†’ AI script generation β†’ Remotion render β†’ poll completion β†’ send video to Telegram
23
+ - Updated `/start` message with Video Engine section
24
+
25
+ ---
26
+
27
  ## Phase 0 + 6 + 7 β€” Quick Wins, SEO-to-Revenue, Self-Optimization
28
  **Date:** 2026-03-27
29
 
database/video_db.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"jobs":[],"stats":{"totalRendered":0,"totalFailed":0}}
public/videos-dashboard.html ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>VinOS β€” Video Engine</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+ :root {
12
+ --bg: #090c12; --surface: #111622; --surface2: #161e2e; --border: #1e2d45;
13
+ --accent: #00e5ff; --accent2: #7c3aed; --green: #22d3a0; --orange: #f59e0b; --red: #ef4444;
14
+ --text: #e2e8f0; --muted: #64748b;
15
+ }
16
+ body { font-family: 'Space Grotesk', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; padding: 20px; }
17
+ .container { max-width: 800px; margin: 0 auto; }
18
+ .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
19
+ .header h1 { font-size: 1.6rem; font-weight: 700; background: linear-gradient(135deg, var(--accent), var(--green)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
20
+ .header .back { color: var(--accent); text-decoration: none; font-size: 0.85rem; }
21
+ .header .back:hover { text-decoration: underline; }
22
+
23
+ .stats-row { display: flex; gap: 12px; margin-bottom: 24px; }
24
+ .stat-card { flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; text-align: center; }
25
+ .stat-card .val { font-size: 1.5rem; font-weight: 700; color: var(--accent); }
26
+ .stat-card .label { font-size: 0.7rem; color: var(--muted); margin-top: 4px; }
27
+
28
+ .section-title { font-size: 1rem; font-weight: 600; margin-bottom: 14px; color: var(--muted); }
29
+
30
+ /* Template Cards */
31
+ .templates { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 28px; }
32
+ .tmpl { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; cursor: pointer; transition: all 0.2s; }
33
+ .tmpl:hover, .tmpl.selected { border-color: var(--accent); box-shadow: 0 0 20px rgba(0,229,255,0.06); }
34
+ .tmpl.selected { background: var(--surface2); }
35
+ .tmpl .ratio { display: inline-block; font-size: 0.6rem; padding: 2px 8px; border-radius: 6px; background: var(--accent)20; color: var(--accent); font-weight: 600; margin-bottom: 6px; }
36
+ .tmpl h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 4px; }
37
+ .tmpl p { font-size: 0.75rem; color: var(--muted); line-height: 1.4; }
38
+
39
+ /* Create Form */
40
+ .create-form { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 24px; margin-bottom: 28px; display: none; }
41
+ .create-form.visible { display: block; }
42
+ .form-group { margin-bottom: 14px; }
43
+ .form-group label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; }
44
+ .form-group input, .form-group textarea {
45
+ width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border);
46
+ border-radius: 8px; color: var(--text); font-family: inherit; font-size: 0.85rem; outline: none;
47
+ }
48
+ .form-group input:focus, .form-group textarea:focus { border-color: var(--accent); }
49
+ .form-group textarea { min-height: 70px; resize: vertical; }
50
+ .btn { padding: 10px 24px; border: none; border-radius: 8px; font-family: inherit; font-weight: 700; cursor: pointer; transition: all 0.2s; font-size: 0.85rem; }
51
+ .btn-primary { background: linear-gradient(135deg, var(--accent), var(--green)); color: #000; }
52
+ .btn-primary:hover { opacity: 0.9; }
53
+ .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
54
+ .btn-sm { padding: 6px 14px; font-size: 0.75rem; border-radius: 6px; }
55
+ .btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
56
+
57
+ /* Jobs */
58
+ .job { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 10px; }
59
+ .job-row { display: flex; justify-content: space-between; align-items: center; }
60
+ .job-id { font-size: 0.7rem; color: var(--muted); font-family: monospace; }
61
+ .badge { font-size: 0.65rem; padding: 3px 10px; border-radius: 6px; font-weight: 600; text-transform: uppercase; }
62
+ .badge-queued { background: var(--orange)20; color: var(--orange); }
63
+ .badge-rendering { background: var(--accent)20; color: var(--accent); }
64
+ .badge-complete { background: var(--green)20; color: var(--green); }
65
+ .badge-failed { background: var(--red)20; color: var(--red); }
66
+ .badge-expired { background: var(--muted)20; color: var(--muted); }
67
+ .progress { height: 3px; background: var(--border); border-radius: 2px; margin-top: 8px; overflow: hidden; }
68
+ .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--green)); transition: width 0.3s; }
69
+ .job-actions { display: flex; gap: 6px; margin-top: 8px; }
70
+ .empty { text-align: center; color: var(--muted); padding: 30px; font-style: italic; font-size: 0.85rem; }
71
+
72
+ /* Player */
73
+ .modal { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 100; justify-content: center; align-items: center; }
74
+ .modal.visible { display: flex; }
75
+ .modal video { max-width: 90vw; max-height: 80vh; border-radius: 10px; }
76
+ .modal-close { position: absolute; top: 16px; right: 24px; font-size: 2rem; color: #fff; cursor: pointer; }
77
+
78
+ @media (max-width: 640px) { .templates { grid-template-columns: 1fr; } .stats-row { flex-wrap: wrap; } .stat-card { min-width: 45%; } }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <div class="container">
83
+ <div class="header">
84
+ <h1>Video Engine</h1>
85
+ <a class="back" href="/">Dashboard</a>
86
+ </div>
87
+
88
+ <div class="stats-row">
89
+ <div class="stat-card"><div class="val" id="sTotal">0</div><div class="label">Rendered</div></div>
90
+ <div class="stat-card"><div class="val" id="sActive">0</div><div class="label">Active</div></div>
91
+ <div class="stat-card"><div class="val" id="sTemplates">4</div><div class="label">Templates</div></div>
92
+ </div>
93
+
94
+ <div class="section-title">Choose Template</div>
95
+ <div class="templates" id="tmplGrid"></div>
96
+
97
+ <div class="create-form" id="createForm">
98
+ <div class="form-group">
99
+ <label>Topic *</label>
100
+ <input type="text" id="inTopic" placeholder="AI Productivity Tips for Founders">
101
+ </div>
102
+ <div class="form-group">
103
+ <label>Duration (seconds)</label>
104
+ <input type="range" id="inDuration" min="10" max="60" value="30" oninput="document.getElementById('durVal').textContent=this.value+'s'">
105
+ <span style="font-size:0.75rem;color:var(--muted)" id="durVal">30s</span>
106
+ </div>
107
+ <button class="btn btn-primary" id="btnCreate" onclick="createVideo()">Generate Video</button>
108
+ </div>
109
+
110
+ <div class="section-title">Recent Jobs</div>
111
+ <div id="jobsList"><div class="empty">No jobs yet</div></div>
112
+ </div>
113
+
114
+ <div class="modal" id="playerModal">
115
+ <div class="modal-close" onclick="closePlayer()">&times;</div>
116
+ <video id="playerVid" controls></video>
117
+ </div>
118
+
119
+ <script>
120
+ const esc = s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
121
+ let templates = [], selectedKey = null, pollId = null;
122
+
123
+ async function loadTemplates() {
124
+ try {
125
+ const r = await fetch('/api/videos/templates');
126
+ const d = await r.json();
127
+ templates = d.templates || [];
128
+ document.getElementById('tmplGrid').innerHTML = templates.map(t => `
129
+ <div class="tmpl" data-key="${t.key}" onclick="selectTmpl('${t.key}')">
130
+ <div class="ratio">${esc(t.ratio)}</div>
131
+ <h3>${esc(t.name)}</h3>
132
+ <p>${esc(t.description)}</p>
133
+ </div>
134
+ `).join('');
135
+ } catch (e) {
136
+ document.getElementById('tmplGrid').innerHTML = '<div class="empty">Remotion Studio offline</div>';
137
+ }
138
+ }
139
+
140
+ function selectTmpl(key) {
141
+ selectedKey = key;
142
+ document.querySelectorAll('.tmpl').forEach(c => c.classList.remove('selected'));
143
+ document.querySelector(`.tmpl[data-key="${key}"]`)?.classList.add('selected');
144
+ document.getElementById('createForm').classList.add('visible');
145
+ }
146
+
147
+ async function createVideo() {
148
+ const topic = document.getElementById('inTopic').value.trim();
149
+ if (!topic || !selectedKey) return alert('Enter a topic and select a template');
150
+ const btn = document.getElementById('btnCreate');
151
+ btn.disabled = true; btn.textContent = 'Submitting...';
152
+ try {
153
+ const r = await fetch('/api/videos/create', {
154
+ method: 'POST',
155
+ headers: { 'Content-Type': 'application/json' },
156
+ body: JSON.stringify({ topic, template: selectedKey, duration: +document.getElementById('inDuration').value })
157
+ });
158
+ const d = await r.json();
159
+ if (d.success) { loadJobs(); startPoll(); }
160
+ else alert(d.error || 'Failed');
161
+ } catch (e) { alert(e.message); }
162
+ btn.disabled = false; btn.textContent = 'Generate Video';
163
+ }
164
+
165
+ async function loadJobs() {
166
+ try {
167
+ const r = await fetch('/api/videos/jobs?limit=15');
168
+ const d = await r.json();
169
+ const jobs = d.jobs || [];
170
+ document.getElementById('sTotal').textContent = jobs.filter(j => j.status === 'complete').length;
171
+ document.getElementById('sActive').textContent = jobs.filter(j => j.status === 'rendering' || j.status === 'queued').length;
172
+ if (!jobs.length) { document.getElementById('jobsList').innerHTML = '<div class="empty">No jobs yet</div>'; return; }
173
+ document.getElementById('jobsList').innerHTML = jobs.map(j => `
174
+ <div class="job">
175
+ <div class="job-row">
176
+ <span class="job-id">${j.id}</span>
177
+ <span class="badge badge-${j.status}">${j.status}</span>
178
+ </div>
179
+ <div style="font-size:0.85rem;margin-top:4px;">${esc(j.template)}</div>
180
+ <div style="font-size:0.7rem;color:var(--muted);">${new Date(j.created).toLocaleString()}</div>
181
+ ${j.status === 'rendering' ? `<div class="progress"><div class="progress-fill" style="width:${j.progress||0}%"></div></div>` : ''}
182
+ ${j.error ? `<div style="color:var(--red);font-size:0.75rem;margin-top:4px;">${esc(j.error)}</div>` : ''}
183
+ <div class="job-actions">
184
+ ${j.status === 'complete' ? `<button class="btn btn-sm btn-primary" onclick="playVid('${j.id}')">Play</button><a href="/api/videos/download/${j.id}" class="btn btn-sm btn-ghost" download>Download</a>` : ''}
185
+ </div>
186
+ </div>
187
+ `).join('');
188
+ const hasActive = jobs.some(j => j.status === 'queued' || j.status === 'rendering');
189
+ if (!hasActive) stopPoll();
190
+ } catch (e) { /* */ }
191
+ }
192
+
193
+ function startPoll() { if (!pollId) pollId = setInterval(loadJobs, 3000); }
194
+ function stopPoll() { if (pollId) { clearInterval(pollId); pollId = null; } }
195
+
196
+ function playVid(id) {
197
+ const v = document.getElementById('playerVid');
198
+ v.src = `/api/videos/download/${id}`;
199
+ document.getElementById('playerModal').classList.add('visible');
200
+ v.play();
201
+ }
202
+ function closePlayer() {
203
+ const v = document.getElementById('playerVid');
204
+ v.pause(); v.src = '';
205
+ document.getElementById('playerModal').classList.remove('visible');
206
+ }
207
+
208
+ loadTemplates();
209
+ loadJobs();
210
+ </script>
211
+ </body>
212
+ </html>
server.js CHANGED
@@ -59,6 +59,7 @@ const salesEngine = require('./skills/sales_engine');
59
  const mayarClient = require('./skills/mayar_client');
60
  const costTracker = require('./skills/cost_tracker');
61
  const scoutAgent = require('./skills/scout_agent');
 
62
 
63
 
64
 
@@ -97,6 +98,47 @@ app.get('/api/landing', (req, res) => {
97
  } catch (e) { res.json({ success: true, offers: [] }); }
98
  });
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  // ============================================
101
  // SALES ENGINE API
102
  // ============================================
@@ -1342,6 +1384,11 @@ app.post('/api/telegram-webhook', async (req, res) => {
1342
 
1343
  // --- Commands ---
1344
 
 
 
 
 
 
1345
  if (userText && userText.toLowerCase().startsWith('/viral')) {
1346
  await viralFlow.start(chatId, userText);
1347
  return;
@@ -1894,6 +1941,10 @@ This engine researches any URL or topic and generates content with your options.
1894
  `/offer eval β€” Run offer evaluation\n` +
1895
  `/revenue β€” Revenue summary\n` +
1896
  `/revenue today β€” Today's sales only\n\n` +
 
 
 
 
1897
  `πŸ” <b>RESEARCH</b>\n` +
1898
  `/scout [keyword] β€” Market viability scout\n` +
1899
  `/costs β€” API spend breakdown\n` +
 
59
  const mayarClient = require('./skills/mayar_client');
60
  const costTracker = require('./skills/cost_tracker');
61
  const scoutAgent = require('./skills/scout_agent');
62
+ const videoEngine = require('./skills/video_engine');
63
 
64
 
65
 
 
98
  } catch (e) { res.json({ success: true, offers: [] }); }
99
  });
100
 
101
+ // ============================================
102
+ // VIDEO ENGINE API (proxy to Remotion Studio)
103
+ // ============================================
104
+ app.get('/videos-dashboard', (req, res) => {
105
+ res.sendFile(path.join(__dirname, 'public', 'videos-dashboard.html'));
106
+ });
107
+
108
+ app.get('/api/videos/templates', async (req, res) => {
109
+ try {
110
+ const r = await apiCaller.axiosIPv4.get(`${process.env.REMOTION_URL || 'https://AIgoose-remotion-studio.hf.space'}/api/templates`, { timeout: 10000 });
111
+ res.json(r.data);
112
+ } catch (e) { res.json({ templates: [] }); }
113
+ });
114
+
115
+ app.post('/api/videos/create', async (req, res) => {
116
+ try {
117
+ const { topic, template, duration } = req.body;
118
+ // Generate script via video engine then submit
119
+ const chatId = 'web';
120
+ await videoEngine.createVideo(chatId, topic || 'AI Technology', template || 'text-explainer');
121
+ res.json({ success: true, message: 'Video queued. Check the job queue.' });
122
+ } catch (e) { res.json({ success: false, error: e.message }); }
123
+ });
124
+
125
+ app.get('/api/videos/jobs', async (req, res) => {
126
+ try {
127
+ const r = await apiCaller.axiosIPv4.get(`${process.env.REMOTION_URL || 'https://AIgoose-remotion-studio.hf.space'}/api/jobs?limit=${req.query.limit || 20}`, { timeout: 10000 });
128
+ res.json(r.data);
129
+ } catch (e) { res.json({ jobs: [] }); }
130
+ });
131
+
132
+ app.get('/api/videos/download/:id', async (req, res) => {
133
+ try {
134
+ const remotionUrl = process.env.REMOTION_URL || 'https://AIgoose-remotion-studio.hf.space';
135
+ const r = await apiCaller.axiosIPv4.get(`${remotionUrl}/api/download/${req.params.id}`, { responseType: 'stream', timeout: 60000 });
136
+ res.set('Content-Type', 'video/mp4');
137
+ res.set('Content-Disposition', `attachment; filename="${req.params.id}.mp4"`);
138
+ r.data.pipe(res);
139
+ } catch (e) { res.status(404).json({ error: 'Video not available' }); }
140
+ });
141
+
142
  // ============================================
143
  // SALES ENGINE API
144
  // ============================================
 
1384
 
1385
  // --- Commands ---
1386
 
1387
+ if (userText && userText.toLowerCase().startsWith('/videos')) {
1388
+ await videoEngine.start(chatId, userText);
1389
+ return;
1390
+ }
1391
+
1392
  if (userText && userText.toLowerCase().startsWith('/viral')) {
1393
  await viralFlow.start(chatId, userText);
1394
  return;
 
1941
  `/offer eval β€” Run offer evaluation\n` +
1942
  `/revenue β€” Revenue summary\n` +
1943
  `/revenue today β€” Today's sales only\n\n` +
1944
+ `🎬 <b>VIDEO ENGINE</b>\n` +
1945
+ `/videos create [topic] β€” AI-generate + render video\n` +
1946
+ `/videos templates β€” List video templates\n` +
1947
+ `/videos status β€” Render queue status\n\n` +
1948
  `πŸ” <b>RESEARCH</b>\n` +
1949
  `/scout [keyword] β€” Market viability scout\n` +
1950
  `/costs β€” API spend breakdown\n` +
skills/api_caller.js CHANGED
@@ -544,4 +544,26 @@ module.exports = {
544
  return { success: false, error: error.message };
545
  }
546
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  };
 
544
  return { success: false, error: error.message };
545
  }
546
  },
547
+
548
+ // Telegram (Send Video)
549
+ sendTelegramVideo: async (chatId, filePath, caption = "") => {
550
+ const formData = new FormData();
551
+ formData.append('chat_id', chatId);
552
+ formData.append('video', fs.createReadStream(filePath));
553
+ formData.append('caption', caption);
554
+ formData.append('parse_mode', 'HTML');
555
+
556
+ console.log(`[Telegram] Sending video to ${chatId}: ${path.basename(filePath)}`);
557
+ try {
558
+ const response = await axiosIPv4.post(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendVideo`, formData, {
559
+ headers: formData.getHeaders(),
560
+ maxContentLength: 50 * 1024 * 1024,
561
+ maxBodyLength: 50 * 1024 * 1024,
562
+ });
563
+ return { success: true, data: response.data };
564
+ } catch (error) {
565
+ console.error("[Telegram] API Video Error:", error.response?.data || error.message);
566
+ return { success: false, error: error.message };
567
+ }
568
+ },
569
  };
skills/set_telegram_menu.js CHANGED
@@ -40,7 +40,8 @@ const setCommands = async () => {
40
  { command: 'costs', description: 'πŸ’Έ API Spend Breakdown' },
41
  { command: 'scout', description: 'πŸ” Market Viability Scout' },
42
  { command: 'seo2offer', description: '🎯 Full SEO-to-Revenue Pipeline' },
43
- { command: 'landing', description: 'πŸ”— Link-in-Bio Page' }
 
44
  ];
45
 
46
  try {
 
40
  { command: 'costs', description: 'πŸ’Έ API Spend Breakdown' },
41
  { command: 'scout', description: 'πŸ” Market Viability Scout' },
42
  { command: 'seo2offer', description: '🎯 Full SEO-to-Revenue Pipeline' },
43
+ { command: 'landing', description: 'πŸ”— Link-in-Bio Page' },
44
+ { command: 'videos', description: '🎬 AI Video Generator' }
45
  ];
46
 
47
  try {
skills/video_engine.js ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const apiCaller = require('./api_caller');
4
+ const memory = require('./memory');
5
+
6
+ const REMOTION_URL = process.env.REMOTION_URL || 'https://AIgoose-remotion-studio.hf.space';
7
+ const REMOTION_KEY = process.env.REMOTION_API_KEY || '';
8
+ const DB_PATH = path.join(__dirname, '../database/video_db.json');
9
+ const TMP_DIR = path.join(__dirname, '../public/tmp/videos');
10
+
11
+ // Ensure dirs exist
12
+ if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
13
+
14
+ function readDB() {
15
+ try {
16
+ if (fs.existsSync(DB_PATH)) return JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
17
+ } catch (e) { /* */ }
18
+ return { jobs: [], stats: { totalRendered: 0, totalFailed: 0 } };
19
+ }
20
+
21
+ function writeDB(db) {
22
+ fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2));
23
+ }
24
+
25
+ function remotionHeaders() {
26
+ const headers = { 'Content-Type': 'application/json' };
27
+ if (REMOTION_KEY) headers['Authorization'] = `Bearer ${REMOTION_KEY}`;
28
+ return headers;
29
+ }
30
+
31
+ const TEMPLATE_PROMPTS = {
32
+ 'text-explainer': {
33
+ system: 'You are a content strategist. Create engaging explainer video content. Output JSON only.',
34
+ format: '{"title":"string","hook":"string","bullets":["string","string","string"],"cta":"string"}',
35
+ },
36
+ 'product-promo': {
37
+ system: 'You are a sales copywriter. Create compelling product promo video content. Output JSON only.',
38
+ format: '{"productName":"string","price":"string","benefits":["string","string","string"],"cta":"string"}',
39
+ },
40
+ 'social-short': {
41
+ system: 'You are a viral content creator for TikTok/Reels. Create scroll-stopping vertical video content. Output JSON only.',
42
+ format: '{"hook":"string","points":["string","string","string"],"cta":"string"}',
43
+ },
44
+ 'seo-summary': {
45
+ system: 'You are an SEO content strategist. Create article summary video content. Output JSON only.',
46
+ format: '{"articleTitle":"string","keyPoints":["string","string","string"],"source":"VinOS Blog","cta":"Read the full article"}',
47
+ },
48
+ };
49
+
50
+ module.exports = {
51
+ async start(chatId, userText) {
52
+ const args = userText.replace(/^\/videos?\s*/i, '').trim();
53
+
54
+ if (!args || args === 'help') {
55
+ await apiCaller.sendTelegramMessage(chatId,
56
+ `🎬 <b>Video Engine</b>\n\n` +
57
+ `<b>Commands:</b>\n` +
58
+ `<code>/videos create [topic]</code> β€” AI generates + renders video\n` +
59
+ `<code>/videos create [topic] --template social-short</code> β€” specific template\n` +
60
+ `<code>/videos templates</code> β€” list available templates\n` +
61
+ `<code>/videos status</code> β€” show render queue\n` +
62
+ `<code>/videos status [id]</code> β€” check specific job\n\n` +
63
+ `<b>Templates:</b> text-explainer, product-promo, social-short, seo-summary\n\n` +
64
+ `πŸ”— Web UI: ${REMOTION_URL}`
65
+ );
66
+ return;
67
+ }
68
+
69
+ if (args.toLowerCase() === 'templates') {
70
+ return await this.listTemplates(chatId);
71
+ }
72
+
73
+ if (args.toLowerCase().startsWith('status')) {
74
+ const jobId = args.replace(/^status\s*/i, '').trim();
75
+ return await this.checkStatus(chatId, jobId || null);
76
+ }
77
+
78
+ if (args.toLowerCase().startsWith('create')) {
79
+ const createArgs = args.replace(/^create\s*/i, '').trim();
80
+ let template = 'text-explainer';
81
+ let topic = createArgs;
82
+
83
+ // Parse --template flag
84
+ const templateMatch = createArgs.match(/--template\s+(\S+)/i);
85
+ if (templateMatch) {
86
+ template = templateMatch[1];
87
+ topic = createArgs.replace(/--template\s+\S+/i, '').trim();
88
+ }
89
+
90
+ if (!topic) {
91
+ await apiCaller.sendTelegramMessage(chatId, '⚠️ Please provide a topic: <code>/videos create AI Productivity Tips</code>');
92
+ return;
93
+ }
94
+
95
+ return await this.createVideo(chatId, topic, template);
96
+ }
97
+
98
+ await apiCaller.sendTelegramMessage(chatId, '❓ Unknown command. Try <code>/videos help</code>');
99
+ },
100
+
101
+ async listTemplates(chatId) {
102
+ try {
103
+ const res = await apiCaller.axiosIPv4.get(`${REMOTION_URL}/api/templates`, {
104
+ headers: remotionHeaders(),
105
+ timeout: 10000,
106
+ });
107
+ const templates = res.data.templates || [];
108
+
109
+ let msg = '🎬 <b>Video Templates</b>\n\n';
110
+ for (const t of templates) {
111
+ msg += `πŸ“ <b>${t.name}</b> [${t.ratio}]\n`;
112
+ msg += `${t.description}\n`;
113
+ msg += `Key: <code>${t.key}</code>\n\n`;
114
+ }
115
+ msg += `Usage: <code>/videos create [topic] --template [key]</code>`;
116
+ await apiCaller.sendTelegramMessage(chatId, msg);
117
+ } catch (e) {
118
+ await apiCaller.sendTelegramMessage(chatId,
119
+ `⚠️ Remotion Studio unreachable.\nπŸ”— ${REMOTION_URL}\nError: ${e.message}`
120
+ );
121
+ }
122
+ },
123
+
124
+ async createVideo(chatId, topic, template = 'text-explainer') {
125
+ const promptConfig = TEMPLATE_PROMPTS[template];
126
+ if (!promptConfig) {
127
+ await apiCaller.sendTelegramMessage(chatId,
128
+ `⚠️ Unknown template: ${template}\nAvailable: ${Object.keys(TEMPLATE_PROMPTS).join(', ')}`
129
+ );
130
+ return;
131
+ }
132
+
133
+ await apiCaller.sendTelegramMessage(chatId,
134
+ `🎬 <b>Video Pipeline Started</b>\n\n` +
135
+ `πŸ“‹ Topic: ${topic}\n` +
136
+ `πŸ“ Template: ${template}\n` +
137
+ `[β– β–‘β–‘β–‘β–‘] 20% β€” Generating AI script...`
138
+ );
139
+
140
+ // Step 1: Generate script via LLM
141
+ const prompt = [
142
+ { role: 'system', content: promptConfig.system },
143
+ { role: 'user', content: `Topic: "${topic}"\n\nCreate compelling video content for this topic.\nOutput format: ${promptConfig.format}` },
144
+ ];
145
+
146
+ const aiResult = await apiCaller.callOpenRouter(prompt);
147
+ let inputProps = {};
148
+
149
+ if (aiResult.success) {
150
+ try {
151
+ inputProps = JSON.parse(aiResult.data.replace(/```json?\n?|```/g, '').trim());
152
+ } catch (e) {
153
+ await apiCaller.sendTelegramMessage(chatId, '⚠️ AI script generation failed (parse error). Retrying with defaults...');
154
+ inputProps = this._getDefaultProps(template, topic);
155
+ }
156
+ } else {
157
+ inputProps = this._getDefaultProps(template, topic);
158
+ }
159
+
160
+ // Add brand color
161
+ inputProps.brandColor = inputProps.brandColor || '#22d3a0';
162
+
163
+ await apiCaller.sendTelegramMessage(chatId,
164
+ `[β– β– β–‘β–‘β–‘] 40% β€” Script ready. Sending to Remotion Studio...`
165
+ );
166
+
167
+ // Step 2: Submit render to Remotion Studio
168
+ try {
169
+ const res = await apiCaller.axiosIPv4.post(`${REMOTION_URL}/api/render`, {
170
+ template,
171
+ inputProps,
172
+ duration: 30,
173
+ }, {
174
+ headers: remotionHeaders(),
175
+ timeout: 15000,
176
+ });
177
+
178
+ const { jobId, estimatedTime } = res.data;
179
+
180
+ // Save to local DB
181
+ const db = readDB();
182
+ db.jobs.push({
183
+ id: jobId,
184
+ chatId: String(chatId),
185
+ topic,
186
+ template,
187
+ inputProps,
188
+ status: 'rendering',
189
+ created: new Date().toISOString(),
190
+ });
191
+ writeDB(db);
192
+
193
+ await apiCaller.sendTelegramMessage(chatId,
194
+ `[β– β– β– β–‘β–‘] 60% β€” Rendering on Remotion Studio...\n\n` +
195
+ `πŸ†” Job: <code>${jobId}</code>\n` +
196
+ `⏱ Estimated: ${estimatedTime}\n\n` +
197
+ `I'll notify you when it's done!\nCheck progress: <code>/videos status ${jobId}</code>`
198
+ );
199
+
200
+ // Step 3: Poll for completion
201
+ this._pollAndDeliver(chatId, jobId);
202
+
203
+ } catch (e) {
204
+ const errMsg = e.response?.data?.error || e.message;
205
+ await apiCaller.sendTelegramMessage(chatId,
206
+ `⚠️ <b>Render submission failed</b>\n${errMsg}\n\nπŸ”— Try the web UI: ${REMOTION_URL}`
207
+ );
208
+ }
209
+ },
210
+
211
+ async _pollAndDeliver(chatId, jobId) {
212
+ const maxAttempts = 60; // 60 * 3s = 3 min max
213
+ let attempts = 0;
214
+
215
+ const poll = async () => {
216
+ attempts++;
217
+ if (attempts > maxAttempts) {
218
+ await apiCaller.sendTelegramMessage(chatId,
219
+ `⏰ Render timed out for job <code>${jobId}</code>.\nCheck: ${REMOTION_URL}`
220
+ );
221
+ return;
222
+ }
223
+
224
+ try {
225
+ const res = await apiCaller.axiosIPv4.get(`${REMOTION_URL}/api/jobs/${jobId}`, {
226
+ headers: remotionHeaders(),
227
+ timeout: 10000,
228
+ });
229
+
230
+ const job = res.data;
231
+
232
+ if (job.status === 'complete') {
233
+ // Update local DB
234
+ const db = readDB();
235
+ const local = db.jobs.find(j => j.id === jobId);
236
+ if (local) { local.status = 'complete'; local.completed = new Date().toISOString(); }
237
+ db.stats.totalRendered++;
238
+ writeDB(db);
239
+
240
+ // Download video
241
+ const videoPath = path.join(TMP_DIR, `${jobId}.mp4`);
242
+ try {
243
+ const dlRes = await apiCaller.axiosIPv4.get(`${REMOTION_URL}/api/download/${jobId}`, {
244
+ responseType: 'arraybuffer',
245
+ timeout: 60000,
246
+ });
247
+ fs.writeFileSync(videoPath, Buffer.from(dlRes.data));
248
+
249
+ // Send to Telegram
250
+ await apiCaller.sendTelegramVideo(chatId, videoPath,
251
+ `🎬 <b>Video Ready!</b>\nπŸ“‹ ${job.inputProps?.title || job.inputProps?.hook || 'Video'}\nπŸ“ ${job.template}`
252
+ );
253
+
254
+ // Cleanup local copy after sending
255
+ setTimeout(() => {
256
+ try { if (fs.existsSync(videoPath)) fs.unlinkSync(videoPath); } catch (e) { /* */ }
257
+ }, 60000);
258
+
259
+ } catch (dlErr) {
260
+ await apiCaller.sendTelegramMessage(chatId,
261
+ `βœ… <b>Video rendered!</b>\n\n` +
262
+ `πŸ”— Download: ${REMOTION_URL}/api/download/${jobId}\n` +
263
+ `(Direct send failed: ${dlErr.message})`
264
+ );
265
+ }
266
+ return;
267
+ }
268
+
269
+ if (job.status === 'failed') {
270
+ const db = readDB();
271
+ const local = db.jobs.find(j => j.id === jobId);
272
+ if (local) { local.status = 'failed'; local.error = job.error; }
273
+ db.stats.totalFailed++;
274
+ writeDB(db);
275
+
276
+ await apiCaller.sendTelegramMessage(chatId,
277
+ `❌ <b>Render failed</b>\nπŸ†” ${jobId}\nπŸ’¬ ${job.error || 'Unknown error'}`
278
+ );
279
+ return;
280
+ }
281
+
282
+ // Still rendering β€” poll again
283
+ setTimeout(poll, 3000);
284
+ } catch (e) {
285
+ // Network error β€” retry
286
+ setTimeout(poll, 5000);
287
+ }
288
+ };
289
+
290
+ setTimeout(poll, 5000); // First check after 5s
291
+ },
292
+
293
+ async checkStatus(chatId, jobId) {
294
+ if (jobId) {
295
+ // Check specific job
296
+ try {
297
+ const res = await apiCaller.axiosIPv4.get(`${REMOTION_URL}/api/jobs/${jobId}`, {
298
+ headers: remotionHeaders(),
299
+ timeout: 10000,
300
+ });
301
+ const j = res.data;
302
+ await apiCaller.sendTelegramMessage(chatId,
303
+ `🎬 <b>Job Status</b>\n\n` +
304
+ `πŸ†” ${j.id}\n` +
305
+ `πŸ“ Template: ${j.template}\n` +
306
+ `πŸ“Š Status: ${j.status}${j.progress ? ` (${j.progress}%)` : ''}\n` +
307
+ `πŸ“… Created: ${new Date(j.created).toLocaleString()}\n` +
308
+ (j.status === 'complete' ? `πŸ”— Download: ${REMOTION_URL}/api/download/${j.id}\n` : '') +
309
+ (j.error ? `❌ Error: ${j.error}\n` : '')
310
+ );
311
+ } catch (e) {
312
+ await apiCaller.sendTelegramMessage(chatId, `⚠️ Job not found or Remotion unreachable: ${e.message}`);
313
+ }
314
+ } else {
315
+ // List recent jobs
316
+ try {
317
+ const res = await apiCaller.axiosIPv4.get(`${REMOTION_URL}/api/jobs?limit=5`, {
318
+ headers: remotionHeaders(),
319
+ timeout: 10000,
320
+ });
321
+ const jobList = res.data.jobs || [];
322
+ if (jobList.length === 0) {
323
+ await apiCaller.sendTelegramMessage(chatId, 'πŸ“­ No video jobs yet. Create one: <code>/videos create [topic]</code>');
324
+ return;
325
+ }
326
+ let msg = '🎬 <b>Recent Video Jobs</b>\n\n';
327
+ for (const j of jobList) {
328
+ const icon = j.status === 'complete' ? 'βœ…' : j.status === 'rendering' ? '⏳' : j.status === 'queued' ? 'πŸ“‹' : '❌';
329
+ msg += `${icon} <code>${j.id}</code>\n ${j.template} β€” ${j.status}${j.progress ? ` (${j.progress}%)` : ''}\n\n`;
330
+ }
331
+ await apiCaller.sendTelegramMessage(chatId, msg);
332
+ } catch (e) {
333
+ await apiCaller.sendTelegramMessage(chatId, `⚠️ Remotion Studio unreachable: ${e.message}`);
334
+ }
335
+ }
336
+ },
337
+
338
+ _getDefaultProps(template, topic) {
339
+ const defaults = {
340
+ 'text-explainer': { title: topic, hook: `Everything you need to know about ${topic}`, bullets: [`Key insight about ${topic}`, `Why ${topic} matters`, `How to get started`], cta: 'Learn More' },
341
+ 'product-promo': { productName: topic, price: 'Rp 49.000', benefits: [`Master ${topic}`, 'Step-by-step guide', 'Lifetime access'], cta: 'Get It Now' },
342
+ 'social-short': { hook: `Stop scrolling. ${topic} is changing everything.`, points: [`${topic} saves you hours`, `Most people don't know this`, 'Here\'s the proof'], cta: 'Link in bio' },
343
+ 'seo-summary': { articleTitle: topic, keyPoints: [`Key finding about ${topic}`, 'The data behind it', 'What to do next'], source: 'VinOS Blog', cta: 'Read the full article' },
344
+ };
345
+ return defaults[template] || defaults['text-explainer'];
346
+ },
347
+
348
+ getDashboardData() {
349
+ const db = readDB();
350
+ return {
351
+ jobs: db.jobs.slice(-20).reverse(),
352
+ stats: db.stats,
353
+ remotionUrl: REMOTION_URL,
354
+ };
355
+ },
356
+ };