Spaces:
Configuration error
Configuration error
Dee Ferdinand commited on
Commit ·
345fbd8
1
Parent(s): a60741e
feat: init Dee Video Studio — HyperFrames cloud render for testimonial/teaser/trailer videos
Browse files- 4 workflows: testimonial (75s), teaser (30s), trailer (60s), community (60s)
- 6 royalty-free Pixabay music tracks auto-selected by workflow
- WebSocket real-time render progress
- Dark-mode Studio UI with drag-drop upload
- HuggingFace Docker Space ready (port 7860)
- lib/composer.js +107 -0
lib/composer.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from 'fs';
|
| 2 |
+
import path from 'path';
|
| 3 |
+
|
| 4 |
+
function pickMedia(mediaList, preferVideo = true, index = 0) {
|
| 5 |
+
const videos = mediaList.filter(m => m.mime?.startsWith('video/'));
|
| 6 |
+
const images = mediaList.filter(m => m.mime?.startsWith('image/'));
|
| 7 |
+
if (preferVideo && videos.length > index) return videos[index];
|
| 8 |
+
if (images.length > index) return images[index];
|
| 9 |
+
return mediaList[index % Math.max(mediaList.length, 1)] || null;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function captionOverlay(text, startSec, durationSec, style = 'default') {
|
| 13 |
+
const styles = {
|
| 14 |
+
default: 'font-size:42px;font-weight:700;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,0.8);text-align:center;padding:0 40px;',
|
| 15 |
+
badge: 'font-size:22px;font-weight:600;color:#fff;background:rgba(0,0,0,0.6);padding:8px 20px;border-radius:8px;',
|
| 16 |
+
hook: 'font-size:56px;font-weight:900;color:#fff;text-shadow:0 4px 16px rgba(0,0,0,0.9);line-height:1.1;text-align:center;padding:0 32px;',
|
| 17 |
+
stat: 'font-size:68px;font-weight:900;color:#fff;text-shadow:0 4px 20px rgba(0,0,0,0.85);text-align:center;',
|
| 18 |
+
brand: 'font-size:28px;font-weight:500;color:#fff;letter-spacing:0.04em;text-align:center;',
|
| 19 |
+
};
|
| 20 |
+
return `
|
| 21 |
+
<div class="clip caption-overlay"
|
| 22 |
+
data-start="${startSec}"
|
| 23 |
+
data-duration="${durationSec}"
|
| 24 |
+
data-track-index="10"
|
| 25 |
+
style="position:absolute;bottom:140px;left:0;right:0;display:flex;align-items:flex-end;justify-content:center;pointer-events:none;">
|
| 26 |
+
<span style="${styles[style] || styles.default}">${text}</span>
|
| 27 |
+
</div>`;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function clipBlock(srcRel, startSec, durationSec, trackIdx) {
|
| 31 |
+
return `
|
| 32 |
+
<video class="clip" data-start="${startSec}" data-duration="${durationSec}" data-track-index="${trackIdx}"
|
| 33 |
+
src="${srcRel}" muted playsinline style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></video>`;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function imageBlock(srcRel, startSec, durationSec, trackIdx) {
|
| 37 |
+
return `
|
| 38 |
+
<img class="clip" data-start="${startSec}" data-duration="${durationSec}" data-track-index="${trackIdx}"
|
| 39 |
+
src="${srcRel}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;">`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export async function buildComposition(compDir, projectDir, musicPath, config, workflow, onProgress) {
|
| 43 |
+
const files = config._files || [];
|
| 44 |
+
const { trainerName, tagline, website, clientName, format, duration: totalDur } = config;
|
| 45 |
+
|
| 46 |
+
const assetsDir = path.join(compDir, 'assets');
|
| 47 |
+
fs.mkdirSync(assetsDir, { recursive: true });
|
| 48 |
+
|
| 49 |
+
const linkedMedia = [];
|
| 50 |
+
for (const f of files) {
|
| 51 |
+
const dest = path.join(assetsDir, path.basename(f.path));
|
| 52 |
+
if (!fs.existsSync(dest)) fs.copyFileSync(f.path, dest);
|
| 53 |
+
linkedMedia.push({ ...f, localPath: dest, rel: `assets/${path.basename(f.path)}` });
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const musicDest = path.join(assetsDir, path.basename(musicPath));
|
| 57 |
+
if (!fs.existsSync(musicDest)) fs.copyFileSync(musicPath, musicDest);
|
| 58 |
+
const musicRel = `assets/${path.basename(musicPath)}`;
|
| 59 |
+
|
| 60 |
+
onProgress?.(0.2);
|
| 61 |
+
const cuts = workflow.generateCuts(linkedMedia, config);
|
| 62 |
+
onProgress?.(0.5);
|
| 63 |
+
|
| 64 |
+
const [w, h] = format === '9:16' ? [1080, 1920] : format === '1:1' ? [1080, 1080] : [1920, 1080];
|
| 65 |
+
|
| 66 |
+
let clipBlocks = '';
|
| 67 |
+
let captionBlocks = '';
|
| 68 |
+
let trackIdx = 0;
|
| 69 |
+
|
| 70 |
+
for (const cut of cuts) {
|
| 71 |
+
if (cut.mediaFile) {
|
| 72 |
+
if (cut.mediaFile.mime?.startsWith('video/')) clipBlocks += clipBlock(cut.mediaFile.rel, cut.start, cut.duration, trackIdx++);
|
| 73 |
+
else clipBlocks += imageBlock(cut.mediaFile.rel, cut.start, cut.duration, trackIdx++);
|
| 74 |
+
}
|
| 75 |
+
if (cut.caption) captionBlocks += captionOverlay(cut.caption, cut.start + 0.3, cut.duration - 0.5, cut.captionStyle || 'default');
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const html = `<!DOCTYPE html>
|
| 79 |
+
<html><head><meta charset="utf-8">
|
| 80 |
+
<style>
|
| 81 |
+
* { margin:0;padding:0;box-sizing:border-box; }
|
| 82 |
+
body { background:#000;overflow:hidden; }
|
| 83 |
+
#stage { position:relative;width:${w}px;height:${h}px;overflow:hidden;background:#000; }
|
| 84 |
+
.caption-overlay { animation:fadeIn 0.3s ease-in; }
|
| 85 |
+
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)} }
|
| 86 |
+
</style></head><body>
|
| 87 |
+
<div id="stage" data-composition-id="dee-video" data-start="0" data-width="${w}" data-height="${h}">
|
| 88 |
+
${clipBlocks}
|
| 89 |
+
<audio data-start="0" data-duration="${totalDur}" data-track-index="20" data-volume="${config.musicVolume || 0.3}" src="${musicRel}"></audio>
|
| 90 |
+
${captionBlocks}
|
| 91 |
+
<div class="clip" data-start="0" data-duration="999" data-track-index="11"
|
| 92 |
+
style="position:absolute;bottom:0;left:0;right:0;padding:12px 24px;background:linear-gradient(transparent,rgba(0,0,0,0.65));">
|
| 93 |
+
<span style="font-size:18px;font-weight:700;color:#fff;">${trainerName || 'Dee Ferdinand'}</span>
|
| 94 |
+
<span style="font-size:14px;color:rgba(255,255,255,0.7);margin-left:8px;">· ${tagline || 'AI Corporate Trainer'}</span>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="clip" data-start="${totalDur - 5}" data-duration="5" data-track-index="30"
|
| 97 |
+
style="position:absolute;inset:0;background:rgba(0,0,0,0.88);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;">
|
| 98 |
+
<div style="font-size:44px;font-weight:800;color:#fff;text-align:center;">${trainerName || 'Dee Ferdinand'}</div>
|
| 99 |
+
<div style="font-size:22px;color:rgba(255,255,255,0.8);text-align:center;">${tagline || 'AI Corporate Trainer'}</div>
|
| 100 |
+
<div style="font-size:18px;color:rgba(255,255,255,0.55);margin-top:6px;">${website || 'deeferdinand.com'}</div>
|
| 101 |
+
${clientName ? `<div style="font-size:14px;color:rgba(255,255,255,0.4);margin-top:4px;">${clientName} · ${new Date().getFullYear()}</div>` : ''}
|
| 102 |
+
</div>
|
| 103 |
+
</div></body></html>`;
|
| 104 |
+
|
| 105 |
+
fs.writeFileSync(path.join(compDir, 'index.html'), html);
|
| 106 |
+
onProgress?.(1.0);
|
| 107 |
+
}
|