Dee Ferdinand commited on
Commit
3cd9013
Β·
1 Parent(s): 0eb1d42

feat: upload rendered MP4s to HuggingFace Dataset repo instead of local download

Browse files

- Renders push to AIgoose/video-renders dataset via huggingface_hub
- Studio UI shows HF link + preview instead of download button
- CommitScheduler auto-batches uploads every 30s
- requirements.txt updated with huggingface_hub
- lib/uploader.js handles the Python bridge upload

Files changed (5) hide show
  1. lib/uploader.js +30 -0
  2. requirements.txt +1 -0
  3. server.js +47 -10
  4. static/index.html +92 -33
  5. uploader.py +56 -0
lib/uploader.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { execFile } from 'child_process';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * Upload a rendered MP4 to HuggingFace Dataset repo.
9
+ * Returns { hf_url, viewer_url, repo_id, filename } on success.
10
+ */
11
+ export async function uploadToHuggingFace(localPath, filename) {
12
+ const token = process.env.HF_TOKEN;
13
+ if (!token) throw new Error('HF_TOKEN env var not set β€” cannot upload to HuggingFace');
14
+
15
+ return new Promise((resolve, reject) => {
16
+ const scriptPath = path.join(__dirname, '..', 'uploader.py');
17
+ const python = process.env.PYTHON_PATH || '/app/venv/bin/python3';
18
+
19
+ execFile(python, [scriptPath, localPath, filename, token], { maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
20
+ if (err) { reject(new Error(stderr || err.message)); return; }
21
+ try {
22
+ const result = JSON.parse(stdout.trim());
23
+ if (result.error) { reject(new Error(result.error)); return; }
24
+ resolve(result);
25
+ } catch (e) {
26
+ reject(new Error(`Failed to parse uploader output: ${stdout}`));
27
+ }
28
+ });
29
+ });
30
+ }
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  requests==2.31.0
2
  pydub==0.25.1
3
  numpy==1.26.4
 
 
1
  requests==2.31.0
2
  pydub==0.25.1
3
  numpy==1.26.4
4
+ huggingface_hub==0.23.4
server.js CHANGED
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'url';
10
  import { renderVideo } from './lib/renderer.js';
11
  import { buildComposition } from './lib/composer.js';
12
  import { getMusicTrack } from './lib/music.js';
 
13
  import { WORKFLOWS } from './workflows/index.js';
14
 
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -40,7 +41,12 @@ function push(jobId, data) {
40
  if (job) Object.assign(job, data);
41
  }
42
 
43
- app.get('/api/health', (_, res) => res.json({ status: 'ok', time: new Date().toISOString() }));
 
 
 
 
 
44
 
45
  app.get('/api/workflows', (_, res) => res.json(
46
  Object.entries(WORKFLOWS).map(([id, w]) => ({ id, ...w.meta }))
@@ -102,18 +108,22 @@ app.post('/api/render', async (req, res) => {
102
  setImmediate(() => runJob(jobId, projectDir, config));
103
  });
104
 
 
105
  app.get('/api/download/:jobId', (req, res) => {
106
  const job = jobs.get(req.params.jobId);
107
  if (!job?.outputPath || !fs.existsSync(job.outputPath))
108
- return res.status(404).json({ error: 'Not ready' });
109
  res.download(job.outputPath);
110
  });
111
 
112
  async function runJob(jobId, projectDir, config) {
113
  try {
114
  push(jobId, { status: 'composing', progress: 5 });
115
- const musicPath = await getMusicTrack(config.musicTrack, config.workflow, config.duration,
116
- (d) => push(jobId, d));
 
 
 
117
  push(jobId, { progress: 15 });
118
 
119
  const compDir = path.join(__dirname, 'compositions', 'projects', jobId);
@@ -123,17 +133,44 @@ async function runJob(jobId, projectDir, config) {
123
  if (!workflow) throw new Error(`Unknown workflow: ${config.workflow}`);
124
 
125
  await buildComposition(compDir, projectDir, musicPath, config, workflow,
126
- (p) => push(jobId, { status: 'composing', progress: 15 + Math.round(p * 0.4) }));
127
- push(jobId, { status: 'rendering', progress: 55 });
 
128
 
129
  const outDir = path.join(__dirname, 'renders');
130
  fs.mkdirSync(outDir, { recursive: true });
131
  const outputFile = path.join(outDir, `${jobId}.mp4`);
132
 
133
  await renderVideo(compDir, outputFile, config,
134
- (p) => push(jobId, { status: 'rendering', progress: 55 + Math.round(p * 0.4) }));
135
-
136
- push(jobId, { status: 'done', progress: 100, outputPath: outputFile, outputUrl: `/renders/${jobId}.mp4` });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  } catch (err) {
138
  console.error('[job error]', jobId, err);
139
  push(jobId, { status: 'error', error: err.message });
@@ -141,4 +178,4 @@ async function runJob(jobId, projectDir, config) {
141
  }
142
 
143
  const PORT = process.env.PORT || 7860;
144
- server.listen(PORT, () => console.log(`βœ… Dee Video Studio on port ${PORT}`));
 
10
  import { renderVideo } from './lib/renderer.js';
11
  import { buildComposition } from './lib/composer.js';
12
  import { getMusicTrack } from './lib/music.js';
13
+ import { uploadToHuggingFace } from './lib/uploader.js';
14
  import { WORKFLOWS } from './workflows/index.js';
15
 
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
41
  if (job) Object.assign(job, data);
42
  }
43
 
44
+ app.get('/api/health', (_, res) => res.json({
45
+ status: 'ok',
46
+ time: new Date().toISOString(),
47
+ hf_renders_repo: process.env.HF_RENDERS_REPO || 'AIgoose/video-renders',
48
+ hf_token_set: !!process.env.HF_TOKEN,
49
+ }));
50
 
51
  app.get('/api/workflows', (_, res) => res.json(
52
  Object.entries(WORKFLOWS).map(([id, w]) => ({ id, ...w.meta }))
 
108
  setImmediate(() => runJob(jobId, projectDir, config));
109
  });
110
 
111
+ // Legacy local download (fallback if HF upload fails)
112
  app.get('/api/download/:jobId', (req, res) => {
113
  const job = jobs.get(req.params.jobId);
114
  if (!job?.outputPath || !fs.existsSync(job.outputPath))
115
+ return res.status(404).json({ error: 'Not ready or already uploaded to HF' });
116
  res.download(job.outputPath);
117
  });
118
 
119
  async function runJob(jobId, projectDir, config) {
120
  try {
121
  push(jobId, { status: 'composing', progress: 5 });
122
+
123
+ const musicPath = await getMusicTrack(
124
+ config.musicTrack, config.workflow, config.duration,
125
+ (d) => push(jobId, d)
126
+ );
127
  push(jobId, { progress: 15 });
128
 
129
  const compDir = path.join(__dirname, 'compositions', 'projects', jobId);
 
133
  if (!workflow) throw new Error(`Unknown workflow: ${config.workflow}`);
134
 
135
  await buildComposition(compDir, projectDir, musicPath, config, workflow,
136
+ (p) => push(jobId, { status: 'composing', progress: 15 + Math.round(p * 0.35) }));
137
+
138
+ push(jobId, { status: 'rendering', progress: 50 });
139
 
140
  const outDir = path.join(__dirname, 'renders');
141
  fs.mkdirSync(outDir, { recursive: true });
142
  const outputFile = path.join(outDir, `${jobId}.mp4`);
143
 
144
  await renderVideo(compDir, outputFile, config,
145
+ (p) => push(jobId, { status: 'rendering', progress: 50 + Math.round(p * 0.35) }));
146
+
147
+ push(jobId, { status: 'uploading', progress: 88 });
148
+
149
+ // Build a human-readable filename
150
+ const slug = (config.clientName || config.projectName || 'video')
151
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40);
152
+ const ts = new Date().toISOString().slice(0, 10);
153
+ const hfFilename = `${slug}-${config.workflow}-${ts}-${jobId.slice(0, 8)}.mp4`;
154
+
155
+ let hfResult = null;
156
+ try {
157
+ hfResult = await uploadToHuggingFace(outputFile, hfFilename);
158
+ // Clean up local render to save Space disk
159
+ fs.unlinkSync(outputFile);
160
+ } catch (uploadErr) {
161
+ console.warn('[upload warning] HF upload failed, keeping local file:', uploadErr.message);
162
+ }
163
+
164
+ push(jobId, {
165
+ status: 'done',
166
+ progress: 100,
167
+ outputPath: hfResult ? null : outputFile,
168
+ hf_url: hfResult?.hf_url || null,
169
+ viewer_url: hfResult?.viewer_url || null,
170
+ hf_filename: hfResult?.filename || null,
171
+ repo_id: hfResult?.repo_id || null,
172
+ outputUrl: hfResult ? hfResult.hf_url : `/renders/${jobId}.mp4`,
173
+ });
174
  } catch (err) {
175
  console.error('[job error]', jobId, err);
176
  push(jobId, { status: 'error', error: err.message });
 
178
  }
179
 
180
  const PORT = process.env.PORT || 7860;
181
+ server.listen(PORT, () => console.log(`\u2705 Dee Video Studio on port ${PORT}`));
static/index.html CHANGED
@@ -3,9 +3,9 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Dee Video Studio</title>
7
  <style>
8
- :root{--bg:#0f0f0f;--s:#1a1a1a;--s2:#242424;--b:rgba(255,255,255,0.1);--a:#7C6FE0;--a2:#E06F9A;--t:#e8e6e1;--m:#888;--g:#4ade80;--r:#f87171;--rad:12px}
9
  *{box-sizing:border-box;margin:0;padding:0}
10
  body{background:var(--bg);color:var(--t);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}
11
  header{padding:20px 28px;border-bottom:1px solid var(--b);display:flex;align-items:center;gap:12px}
@@ -27,14 +27,12 @@ header h1{font-size:18px;font-weight:700}header span{font-size:12px;color:var(--
27
  .drop-zone.drag{border-color:var(--a);background:rgba(124,111,224,.08)}
28
  .drop-zone input{display:none}
29
  .drop-zone .dz-icon{font-size:28px;margin-bottom:6px}
30
- .drop-zone p{font-size:12px;color:var(--m)}
31
- .drop-zone strong{color:var(--t)}
32
  .file-list{display:flex;flex-direction:column;gap:5px;margin-top:8px}
33
  .file-item{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--s2);border-radius:7px;font-size:11px}
34
  .fi-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
35
  .fi-size{color:var(--m);flex-shrink:0}
36
- .fi-rm{background:none;border:none;color:var(--m);cursor:pointer;font-size:13px;padding:0 3px}
37
- .fi-rm:hover{color:var(--r)}
38
  .field{margin-bottom:11px}
39
  .field label{display:block;font-size:11px;color:var(--m);margin-bottom:4px}
40
  .field input,.field select{width:100%;padding:7px 9px;background:var(--s2);border:1px solid var(--b);border-radius:7px;color:var(--t);font-size:12px;outline:none}
@@ -56,14 +54,17 @@ header h1{font-size:18px;font-weight:700}header span{font-size:12px;color:var(--
56
  .sq{background:rgba(251,191,36,.15);color:#fbbf24}
57
  .sc{background:rgba(124,111,224,.2);color:#a78bfa}
58
  .sr{background:rgba(56,189,248,.15);color:#38bdf8}
 
59
  .sd{background:rgba(74,222,128,.15);color:var(--g)}
60
  .se{background:rgba(248,113,113,.15);color:var(--r)}
61
  .prog-bar{height:4px;background:var(--s2);border-radius:99px;overflow:hidden;margin-bottom:8px}
62
  .prog-fill{height:100%;background:linear-gradient(90deg,var(--a),var(--a2));border-radius:99px;transition:width .3s}
63
- .job-actions{display:flex;gap:7px}
64
- .btn-sm{padding:5px 12px;border-radius:7px;font-size:11px;font-weight:600;cursor:pointer;border:none;transition:all .15s}
65
- .btn-dl{background:var(--g);color:#000}.btn-dl:hover{background:#22c55e}
 
66
  .btn-rt{background:var(--s2);color:var(--t);border:1px solid var(--b)}
 
67
  .section-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
68
  .section-hdr h2{font-size:15px;font-weight:700}
69
  .section-hdr span{font-size:11px;color:var(--m)}
@@ -73,13 +74,14 @@ header h1{font-size:18px;font-weight:700}header span{font-size:12px;color:var(--
73
  input[type=range]{accent-color:var(--a);cursor:pointer;width:100%}
74
  .toast{position:fixed;bottom:20px;right:20px;padding:10px 16px;background:var(--s);border:1px solid var(--b);border-radius:var(--rad);font-size:12px;z-index:999;animation:tin .2s ease}
75
  @keyframes tin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
 
76
  </style>
77
  </head>
78
  <body>
79
  <header>
80
  <div class="dot"></div>
81
- <h1>🎬 Dee Video Studio</h1>
82
- <span>HyperFrames Β· Cloud Render Β· AI-powered</span>
83
  </header>
84
  <div class="layout">
85
  <div class="sidebar">
@@ -123,36 +125,61 @@ input[type=range]{accent-color:var(--a);cursor:pointer;width:100%}
123
  <input type="range" id="mvol" min="0" max="100" value="30" oninput="document.getElementById('vl').textContent=this.value+'%'">
124
  </div>
125
  </div>
126
- <button class="render-btn" id="rb" onclick="startRender()">β–Ά Render Video</button>
127
  </div>
128
  <div class="main">
 
 
 
129
  <div class="section-hdr">
130
  <h2>Render Queue</h2>
131
  <span id="jc">0 jobs</span>
132
  </div>
133
  <div id="jlist">
134
- <div class="empty"><div class="ei">🎬</div><p>Upload assets and hit Render to create your first video.</p></div>
135
  </div>
136
  </div>
137
  </div>
138
  <script>
139
  const API='';
140
  let wf='testimonial', files=[], jobs={};
 
141
 
142
  async function init(){
 
 
 
 
 
 
 
143
  try{
144
- const [wfs,tracks]=await Promise.all([fetch(API+'/api/workflows').then(r=>r.json()),fetch(API+'/api/music').then(r=>r.json())]);
145
- document.getElementById('wf-grid').innerHTML=wfs.map(w=>`<button class="wf-pill ${w.id===wf?'active':''}" onclick="selWf('${w.id}')"><span class="icon">${w.icon}</span><span class="name">${w.name}</span><span class="desc">${w.description}</span></button>`).join('');
 
 
 
 
 
 
 
 
146
  const mg=document.getElementById('mg');
147
- mg.innerHTML=`<label class="music-item sel"><input type="radio" name="music" value="auto" checked><div class="mi-info"><div class="mi-name">πŸ€– Auto-select</div><div class="mi-tags">Matches your workflow</div></div></label>`
148
- +tracks.map(t=>`<label class="music-item"><input type="radio" name="music" value="${t.file}"><div class="mi-info"><div class="mi-name">${moji(t.mood)} ${cap(t.mood)}</div><div class="mi-tags">${(t.tags||[]).slice(0,3).join(' Β· ')}</div></div><div class="mi-bpm">${t.bpm} bpm</div></label>`).join('');
149
- mg.querySelectorAll('.music-item').forEach(el=>el.addEventListener('click',()=>{mg.querySelectorAll('.music-item').forEach(x=>x.classList.remove('sel'));el.classList.add('sel');}));
 
 
 
 
 
 
150
  }catch(e){console.warn('Init error',e)}
151
  }
152
 
153
  function moji(m){return{corporate:'🏒',cinematic:'🎬',upbeat:'⚑',warm:'🀝',tech:'πŸ€–',energetic:'πŸ”₯'}[m]||'🎡'}
154
  function cap(s){return s?s[0].toUpperCase()+s.slice(1):''}
155
- function selWf(id){wf=id;document.querySelectorAll('.wf-pill').forEach(el=>el.classList.toggle('active',el.getAttribute('onclick')===`selWf('${id}')`))}
156
 
157
  const dz=document.getElementById('dz');
158
  const fi=document.getElementById('fi');
@@ -166,7 +193,12 @@ function addFiles(f){files.push(...f);renderFL()}
166
  function removeFile(i){files.splice(i,1);renderFL()}
167
  function renderFL(){
168
  const l=document.getElementById('fl');
169
- l.innerHTML=files.map((f,i)=>`<div class="file-item"><span>${ficon(f.type)}</span><span class="fi-name">${f.name}</span><span class="fi-size">${fsz(f.size)}</span><button class="fi-rm" onclick="removeFile(${i})">βœ•</button></div>`).join('');
 
 
 
 
 
170
  }
171
  function ficon(m){if(m.startsWith('video/'))return'πŸŽ₯';if(m.startsWith('image/'))return'πŸ–ΌοΈ';if(m.startsWith('audio/'))return'🎡';return'πŸ“„'}
172
  function fsz(b){return b>1024*1024?(b/1024/1024).toFixed(1)+'MB':(b/1024).toFixed(0)+'KB'}
@@ -183,8 +215,12 @@ async function startRender(){
183
  try{
184
  const res=await fetch(API+'/api/render',{method:'POST',body:fd});
185
  const {jobId}=await res.json();
186
- jobs[jobId]={id:jobId,status:'queued',progress:0,projectName:document.getElementById('clientName').value||'Untitled',workflow:wf,createdAt:Date.now()};
187
- renderJobs();connectWS(jobId);toast('βœ… Render started!');
 
 
 
 
188
  }catch(e){toast('❌ '+e.message)}
189
  finally{rb.disabled=false}
190
  }
@@ -192,29 +228,52 @@ async function startRender(){
192
  function connectWS(jobId){
193
  const p=location.protocol==='https:'?'wss':'ws';
194
  const s=new WebSocket(`${p}://${location.host}?job=${jobId}`);
195
- s.onmessage=e=>{Object.assign(jobs[jobId]||{},JSON.parse(e.data));if(!jobs[jobId])jobs[jobId]=JSON.parse(e.data);renderJobs();const d=JSON.parse(e.data);if(d.status==='done'||d.status==='error')s.close()};
 
 
 
 
 
 
196
  }
197
 
198
  function renderJobs(){
199
  const c=document.getElementById('jlist');
200
  const list=Object.values(jobs).sort((a,b)=>b.createdAt-a.createdAt);
201
  document.getElementById('jc').textContent=`${list.length} job${list.length!==1?'s':''}`;
202
- if(!list.length){c.innerHTML='<div class="empty"><div class="ei">🎬</div><p>Upload assets and hit Render to create your first video.</p></div>';return}
203
- const cls={queued:'sq',composing:'sc',rendering:'sr',done:'sd',error:'se'};
 
 
 
 
204
  c.innerHTML=list.map(j=>`<div class="job-item">
205
- <div class="job-header"><div><div class="job-name">${j.projectName||'Untitled'} Β· ${j.workflow||''}</div><div class="job-meta">${new Date(j.createdAt).toLocaleTimeString()} Β· ${(j.id||'').slice(0,8)}…</div></div>
206
- <span class="sbadge ${cls[j.status]||'sq'}">${j.status}</span></div>
 
 
 
 
 
207
  <div class="prog-bar"><div class="prog-fill" style="width:${j.progress||0}%"></div></div>
208
  <div class="job-actions">
209
- ${j.status==='done'?`<button class="btn-sm btn-dl" onclick="dl('${j.id}')">⬇ Download MP4</button>`:''}
210
- ${j.status==='error'?`<span style="font-size:11px;color:var(--r)">${j.error}</span>`:''}
211
- </div></div>`).join('');
 
 
 
 
 
 
 
 
 
212
  }
213
 
214
- function dl(id){window.open(API+'/api/download/'+id,'_blank')}
215
- function toast(msg){const el=document.createElement('div');el.className='toast';el.textContent=msg;document.body.appendChild(el);setTimeout(()=>el.remove(),3000)}
216
 
217
  init();
218
  </script>
219
  </body>
220
- </html>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Hyperframes Video Studio</title>
7
  <style>
8
+ :root{--bg:#0f0f0f;--s:#1a1a1a;--s2:#242424;--b:rgba(255,255,255,0.1);--a:#7C6FE0;--a2:#E06F9A;--t:#e8e6e1;--m:#888;--g:#4ade80;--r:#f87171;--hf:#ff9d00;--rad:12px}
9
  *{box-sizing:border-box;margin:0;padding:0}
10
  body{background:var(--bg);color:var(--t);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;min-height:100vh}
11
  header{padding:20px 28px;border-bottom:1px solid var(--b);display:flex;align-items:center;gap:12px}
 
27
  .drop-zone.drag{border-color:var(--a);background:rgba(124,111,224,.08)}
28
  .drop-zone input{display:none}
29
  .drop-zone .dz-icon{font-size:28px;margin-bottom:6px}
30
+ .drop-zone p{font-size:12px;color:var(--m)}.drop-zone strong{color:var(--t)}
 
31
  .file-list{display:flex;flex-direction:column;gap:5px;margin-top:8px}
32
  .file-item{display:flex;align-items:center;gap:7px;padding:7px 9px;background:var(--s2);border-radius:7px;font-size:11px}
33
  .fi-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
34
  .fi-size{color:var(--m);flex-shrink:0}
35
+ .fi-rm{background:none;border:none;color:var(--m);cursor:pointer;font-size:13px;padding:0 3px}.fi-rm:hover{color:var(--r)}
 
36
  .field{margin-bottom:11px}
37
  .field label{display:block;font-size:11px;color:var(--m);margin-bottom:4px}
38
  .field input,.field select{width:100%;padding:7px 9px;background:var(--s2);border:1px solid var(--b);border-radius:7px;color:var(--t);font-size:12px;outline:none}
 
54
  .sq{background:rgba(251,191,36,.15);color:#fbbf24}
55
  .sc{background:rgba(124,111,224,.2);color:#a78bfa}
56
  .sr{background:rgba(56,189,248,.15);color:#38bdf8}
57
+ .su{background:rgba(255,157,0,.15);color:var(--hf)}
58
  .sd{background:rgba(74,222,128,.15);color:var(--g)}
59
  .se{background:rgba(248,113,113,.15);color:var(--r)}
60
  .prog-bar{height:4px;background:var(--s2);border-radius:99px;overflow:hidden;margin-bottom:8px}
61
  .prog-fill{height:100%;background:linear-gradient(90deg,var(--a),var(--a2));border-radius:99px;transition:width .3s}
62
+ .job-actions{display:flex;gap:7px;flex-wrap:wrap;align-items:center}
63
+ .btn-sm{padding:6px 14px;border-radius:7px;font-size:11px;font-weight:600;cursor:pointer;border:none;transition:all .15s;text-decoration:none;display:inline-flex;align-items:center;gap:5px}
64
+ .btn-hf{background:var(--hf);color:#000}.btn-hf:hover{background:#e88e00}
65
+ .btn-view{background:var(--s2);color:var(--t);border:1px solid var(--b)}.btn-view:hover{border-color:var(--a)}
66
  .btn-rt{background:var(--s2);color:var(--t);border:1px solid var(--b)}
67
+ .hf-badge{font-size:10px;color:var(--hf);display:flex;align-items:center;gap:4px}
68
  .section-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
69
  .section-hdr h2{font-size:15px;font-weight:700}
70
  .section-hdr span{font-size:11px;color:var(--m)}
 
74
  input[type=range]{accent-color:var(--a);cursor:pointer;width:100%}
75
  .toast{position:fixed;bottom:20px;right:20px;padding:10px 16px;background:var(--s);border:1px solid var(--b);border-radius:var(--rad);font-size:12px;z-index:999;animation:tin .2s ease}
76
  @keyframes tin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
77
+ .repo-banner{background:rgba(255,157,0,.08);border:1px solid rgba(255,157,0,.25);border-radius:8px;padding:8px 12px;font-size:11px;color:var(--hf);display:flex;align-items:center;gap:6px;margin-bottom:12px}
78
  </style>
79
  </head>
80
  <body>
81
  <header>
82
  <div class="dot"></div>
83
+ <h1>🎬 Hyperframes Video Studio</h1>
84
+ <span>HyperFrames Β· HuggingFace Cloud Β· AI-powered</span>
85
  </header>
86
  <div class="layout">
87
  <div class="sidebar">
 
125
  <input type="range" id="mvol" min="0" max="100" value="30" oninput="document.getElementById('vl').textContent=this.value+'%'">
126
  </div>
127
  </div>
128
+ <button class="render-btn" id="rb" onclick="startRender()">β–Ά Render &amp; Upload to HuggingFace</button>
129
  </div>
130
  <div class="main">
131
+ <div id="repo-banner" class="repo-banner" style="display:none">
132
+ πŸ€— Renders save to: <a id="repo-link" href="#" target="_blank" style="color:var(--hf);font-weight:600"></a>
133
+ </div>
134
  <div class="section-hdr">
135
  <h2>Render Queue</h2>
136
  <span id="jc">0 jobs</span>
137
  </div>
138
  <div id="jlist">
139
+ <div class="empty"><div class="ei">🎬</div><p>Upload assets and hit Render β€” your video will be saved directly to HuggingFace.</p></div>
140
  </div>
141
  </div>
142
  </div>
143
  <script>
144
  const API='';
145
  let wf='testimonial', files=[], jobs={};
146
+ const RENDERS_REPO = 'AIgoose/video-renders';
147
 
148
  async function init(){
149
+ // Show repo banner
150
+ const banner = document.getElementById('repo-banner');
151
+ const link = document.getElementById('repo-link');
152
+ link.href = `https://huggingface.co/datasets/${RENDERS_REPO}`;
153
+ link.textContent = `datasets/${RENDERS_REPO}`;
154
+ banner.style.display = 'flex';
155
+
156
  try{
157
+ const [wfs,tracks]=await Promise.all([
158
+ fetch(API+'/api/workflows').then(r=>r.json()),
159
+ fetch(API+'/api/music').then(r=>r.json())
160
+ ]);
161
+ document.getElementById('wf-grid').innerHTML=wfs.map(w=>`
162
+ <button class="wf-pill ${w.id===wf?'active':''}" onclick="selWf('${w.id}')">
163
+ <span class="icon">${w.icon}</span>
164
+ <span class="name">${w.name}</span>
165
+ <span class="desc">${w.description}</span>
166
+ </button>`).join('');
167
  const mg=document.getElementById('mg');
168
+ mg.innerHTML=`<label class="music-item sel"><input type="radio" name="music" value="auto" checked>
169
+ <div class="mi-info"><div class="mi-name">πŸ€– Auto-select</div><div class="mi-tags">Matches your workflow</div></div></label>`
170
+ +tracks.map(t=>`<label class="music-item"><input type="radio" name="music" value="${t.file}">
171
+ <div class="mi-info"><div class="mi-name">${moji(t.mood)} ${cap(t.mood)}</div><div class="mi-tags">${(t.tags||[]).slice(0,3).join(' Β· ')}</div></div>
172
+ <div class="mi-bpm">${t.bpm} bpm</div></label>`).join('');
173
+ mg.querySelectorAll('.music-item').forEach(el=>el.addEventListener('click',()=>{
174
+ mg.querySelectorAll('.music-item').forEach(x=>x.classList.remove('sel'));
175
+ el.classList.add('sel');
176
+ }));
177
  }catch(e){console.warn('Init error',e)}
178
  }
179
 
180
  function moji(m){return{corporate:'🏒',cinematic:'🎬',upbeat:'⚑',warm:'🀝',tech:'πŸ€–',energetic:'πŸ”₯'}[m]||'🎡'}
181
  function cap(s){return s?s[0].toUpperCase()+s.slice(1):''}
182
+ function selWf(id){wf=id;document.querySelectorAll('.wf-pill').forEach(el=>el.classList.toggle('active',el.getAttribute('onclick')===`selWf('${id}')`))}
183
 
184
  const dz=document.getElementById('dz');
185
  const fi=document.getElementById('fi');
 
193
  function removeFile(i){files.splice(i,1);renderFL()}
194
  function renderFL(){
195
  const l=document.getElementById('fl');
196
+ l.innerHTML=files.map((f,i)=>`<div class="file-item">
197
+ <span>${ficon(f.type)}</span>
198
+ <span class="fi-name">${f.name}</span>
199
+ <span class="fi-size">${fsz(f.size)}</span>
200
+ <button class="fi-rm" onclick="removeFile(${i})">βœ•</button>
201
+ </div>`).join('');
202
  }
203
  function ficon(m){if(m.startsWith('video/'))return'πŸŽ₯';if(m.startsWith('image/'))return'πŸ–ΌοΈ';if(m.startsWith('audio/'))return'🎡';return'πŸ“„'}
204
  function fsz(b){return b>1024*1024?(b/1024/1024).toFixed(1)+'MB':(b/1024).toFixed(0)+'KB'}
 
215
  try{
216
  const res=await fetch(API+'/api/render',{method:'POST',body:fd});
217
  const {jobId}=await res.json();
218
+ jobs[jobId]={
219
+ id:jobId,status:'queued',progress:0,
220
+ projectName:document.getElementById('clientName').value||'Untitled',
221
+ workflow:wf,createdAt:Date.now()
222
+ };
223
+ renderJobs();connectWS(jobId);toast('βœ… Render started β€” will upload to HuggingFace when done!');
224
  }catch(e){toast('❌ '+e.message)}
225
  finally{rb.disabled=false}
226
  }
 
228
  function connectWS(jobId){
229
  const p=location.protocol==='https:'?'wss':'ws';
230
  const s=new WebSocket(`${p}://${location.host}?job=${jobId}`);
231
+ s.onmessage=e=>{
232
+ const d=JSON.parse(e.data);
233
+ if(!jobs[jobId])jobs[jobId]=d;
234
+ else Object.assign(jobs[jobId],d);
235
+ renderJobs();
236
+ if(d.status==='done'||d.status==='error')s.close();
237
+ };
238
  }
239
 
240
  function renderJobs(){
241
  const c=document.getElementById('jlist');
242
  const list=Object.values(jobs).sort((a,b)=>b.createdAt-a.createdAt);
243
  document.getElementById('jc').textContent=`${list.length} job${list.length!==1?'s':''}`;
244
+ if(!list.length){
245
+ c.innerHTML='<div class="empty"><div class="ei">🎬</div><p>Upload assets and hit Render β€” your video will be saved directly to HuggingFace.</p></div>';
246
+ return;
247
+ }
248
+ const cls={queued:'sq',composing:'sc',rendering:'sr',uploading:'su',done:'sd',error:'se'};
249
+ const labels={queued:'Queued',composing:'Composing',rendering:'Rendering',uploading:'Uploading to HF πŸ€—',done:'Done βœ“',error:'Error'};
250
  c.innerHTML=list.map(j=>`<div class="job-item">
251
+ <div class="job-header">
252
+ <div>
253
+ <div class="job-name">${j.projectName||'Untitled'} Β· ${j.workflow||''}</div>
254
+ <div class="job-meta">${new Date(j.createdAt).toLocaleTimeString()} Β· ${(j.id||'').slice(0,8)}…</div>
255
+ </div>
256
+ <span class="sbadge ${cls[j.status]||'sq'}">${labels[j.status]||j.status}</span>
257
+ </div>
258
  <div class="prog-bar"><div class="prog-fill" style="width:${j.progress||0}%"></div></div>
259
  <div class="job-actions">
260
+ ${j.status==='done'&&j.hf_url ? `
261
+ <a class="btn-sm btn-hf" href="${j.hf_url}" target="_blank">πŸ€— Open on HuggingFace</a>
262
+ <a class="btn-sm btn-view" href="${j.viewer_url||j.hf_url}" target="_blank">πŸ‘ View file</a>
263
+ <span class="hf-badge">πŸ“ ${j.hf_filename||''}</span>
264
+ ` : ''}
265
+ ${j.status==='done'&&!j.hf_url ? `
266
+ <a class="btn-sm btn-view" href="/api/download/${j.id}" target="_blank">⬇ Download MP4</a>
267
+ <span style="font-size:10px;color:var(--m)">Saved locally (HF upload failed)</span>
268
+ ` : ''}
269
+ ${j.status==='error' ? `<span style="font-size:11px;color:var(--r)">${j.error}</span>` : ''}
270
+ </div>
271
+ </div>`).join('');
272
  }
273
 
274
+ function toast(msg){const el=document.createElement('div');el.className='toast';el.textContent=msg;document.body.appendChild(el);setTimeout(()=>el.remove(),3500)}
 
275
 
276
  init();
277
  </script>
278
  </body>
279
+ </html>
uploader.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ uploader.py β€” Upload a rendered MP4 to HuggingFace Dataset repo.
4
+ Called from Node.js as a child process:
5
+ python3 uploader.py <local_mp4_path> <remote_filename> <hf_token>
6
+ Prints JSON result to stdout.
7
+ """
8
+ import sys, json, os
9
+ from huggingface_hub import HfApi
10
+
11
+ def main():
12
+ if len(sys.argv) < 4:
13
+ print(json.dumps({"error": "Usage: uploader.py <path> <filename> <token>"}))
14
+ sys.exit(1)
15
+
16
+ local_path = sys.argv[1]
17
+ remote_filename = sys.argv[2]
18
+ hf_token = sys.argv[3]
19
+
20
+ REPO_ID = os.environ.get("HF_RENDERS_REPO", "AIgoose/video-renders")
21
+ REPO_TYPE = "dataset"
22
+
23
+ try:
24
+ api = HfApi(token=hf_token)
25
+
26
+ # Ensure the dataset repo exists (create if not)
27
+ try:
28
+ api.repo_info(repo_id=REPO_ID, repo_type=REPO_TYPE)
29
+ except Exception:
30
+ api.create_repo(repo_id=REPO_ID, repo_type=REPO_TYPE, private=False, exist_ok=True)
31
+
32
+ # Upload the file
33
+ url = api.upload_file(
34
+ path_or_fileobj=local_path,
35
+ path_in_repo=f"renders/{remote_filename}",
36
+ repo_id=REPO_ID,
37
+ repo_type=REPO_TYPE,
38
+ commit_message=f"render: {remote_filename}",
39
+ )
40
+
41
+ hf_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/renders/{remote_filename}"
42
+ viewer_url = f"https://huggingface.co/datasets/{REPO_ID}/blob/main/renders/{remote_filename}"
43
+
44
+ print(json.dumps({
45
+ "success": True,
46
+ "hf_url": hf_url,
47
+ "viewer_url": viewer_url,
48
+ "repo_id": REPO_ID,
49
+ "filename": remote_filename,
50
+ }))
51
+ except Exception as e:
52
+ print(json.dumps({"error": str(e)}))
53
+ sys.exit(1)
54
+
55
+ if __name__ == "__main__":
56
+ main()