Spaces:
Configuration error
Configuration error
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
- lib/uploader.js +30 -0
- requirements.txt +1 -0
- server.js +47 -10
- static/index.html +92 -33
- 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 127 |
-
|
|
|
|
| 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:
|
| 135 |
-
|
| 136 |
-
push(jobId, { status: '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(`
|
|
|
|
| 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>
|
| 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:
|
| 65 |
-
.btn-
|
|
|
|
| 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>π¬
|
| 82 |
-
<span>HyperFrames Β· Cloud
|
| 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
|
| 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
|
| 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([
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
const mg=document.getElementById('mg');
|
| 147 |
-
mg.innerHTML=`<label class="music-item sel"><input type="radio" name="music" value="auto" checked>
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]={
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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){
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
c.innerHTML=list.map(j=>`<div class="job-item">
|
| 205 |
-
<div class="job-header">
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
}
|
| 213 |
|
| 214 |
-
function
|
| 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 & 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()
|