Dee Ferdinand commited on
Commit
1f05b41
Β·
1 Parent(s): c0f1ad6

feat: fix black frames + ElevenLabs lo-fi music generation + YuE fallback + complete skill update

Browse files

Black frame fix:
- Root cause: tl.to({},{duration:TOTAL},0) conflicts with GSAP β€” use tl.set({},{},TOTAL) instead
- Add explicit data-duration on root div
- Add 0.5s safety buffer: scenes end 0.3s before next starts (no overlap)
- Volume levels from coleam00 audio-design.md research: music=0.07-0.12, narration=1.0

ElevenLabs music (primary):
- client.music.compose() with model_id='music_v2'
- Prompt: 'Lo-fi upbeat instrumental, jazzy piano, warm vinyl crackle, positive energy, 85-95 BPM'
- music_length_ms = duration * 1000 + 5000 (always generate 5s extra for safety)
- Requires ELEVENLABS_API_KEY env var in HF Space secrets

YuE fallback:
- Lightweight API wrapper if ElevenLabs key not available
- Direct HTTP to YuE inference endpoint

Transition rules from coleam00 transitions.md:
- EXIT ANIMATIONS BANNED (except final scene) β€” transitions ARE the exit
- Scene content must be fully visible when transition starts
- 0.3-0.5s transitions for medium energy (corporate)
- autoAlpha not opacity for HyperFrames (sets visibility:hidden at 0)

Files changed (3) hide show
  1. lib/music.js +150 -105
  2. skills/hyperframes-video/SKILL.md +223 -160
  3. workflows/index.js +217 -242
lib/music.js CHANGED
@@ -10,145 +10,190 @@ const execFileAsync = promisify(execFile);
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
  const LIB = path.join(__dirname, '..', 'music', 'library');
12
 
13
- // Working royalty-free tracks β€” server-accessible (not CDN-blocked)
14
- export const MUSIC_LIBRARY = [
15
- {
16
- file: 'corporate-motivation.mp3', mood: 'corporate', bpm: 120,
17
- tags: ['corporate', 'testimonial', 'professional'],
18
- // Multiple fallback URLs β€” tries each until one works
19
- urls: [
20
- 'https://www.bensound.com/bensound-music/bensound-corporate.mp3',
21
- 'https://www.bensound.com/bensound-music/bensound-ukulele.mp3',
22
- 'https://incompetech.com/music/royalty-free/mp3-royaltyfree/Sunshine.mp3',
23
- ],
24
- license: 'Bensound free license',
25
- },
26
- {
27
- file: 'inspiring-cinematic.mp3', mood: 'cinematic', bpm: 90,
28
- tags: ['cinematic', 'trailer', 'emotional'],
29
- urls: [
30
- 'https://www.bensound.com/bensound-music/bensound-epic.mp3',
31
- 'https://incompetech.com/music/royalty-free/mp3-royaltyfree/Hyperfun.mp3',
32
- ],
33
- license: 'Bensound free license',
34
- },
35
- {
36
- file: 'upbeat-community.mp3', mood: 'upbeat', bpm: 128,
37
- tags: ['community', 'energetic', 'positive'],
38
- urls: [
39
- 'https://www.bensound.com/bensound-music/bensound-happyrock.mp3',
40
- 'https://incompetech.com/music/royalty-free/mp3-royaltyfree/Digital%20Lemonade.mp3',
41
- ],
42
- license: 'Bensound free license',
43
- },
44
- {
45
- file: 'warm-documentary.mp3', mood: 'warm', bpm: 75,
46
- tags: ['community', 'documentary', 'heartfelt'],
47
- urls: [
48
- 'https://www.bensound.com/bensound-music/bensound-slowmotion.mp3',
49
- 'https://incompetech.com/music/royalty-free/mp3-royaltyfree/Fluffing%20a%20Duck.mp3',
50
- ],
51
- license: 'Bensound free license',
52
- },
53
- {
54
- file: 'tech-corporate.mp3', mood: 'tech', bpm: 110,
55
- tags: ['ai', 'tech', 'modern', 'training'],
56
- urls: [
57
- 'https://www.bensound.com/bensound-music/bensound-innovation.mp3',
58
- 'https://incompetech.com/music/royalty-free/mp3-royaltyfree/Local%20Forecast.mp3',
59
- ],
60
- license: 'Bensound free license',
61
- },
62
- {
63
- file: 'action-sport.mp3', mood: 'energetic', bpm: 145,
64
- tags: ['viral', 'energetic', 'teaser'],
65
- urls: [
66
- 'https://www.bensound.com/bensound-music/bensound-highoctane.mp3',
67
- 'https://incompetech.com/music/royalty-free/mp3-royaltyfree/Scheming%20Weasel.mp3',
68
- ],
69
- license: 'Bensound free license',
70
- },
71
- ];
72
-
73
- const MOOD_MAP = {
74
- testimonial: ['corporate', 'warm'],
75
- teaser: ['energetic', 'tech'],
76
- trailer: ['cinematic', 'energetic'],
77
- community: ['warm', 'upbeat'],
78
  };
79
 
80
  export async function getMusicTrack(trackName, workflow, duration, onProgress) {
81
  fs.mkdirSync(LIB, { recursive: true });
82
 
83
- let track;
84
- if (trackName === 'auto') {
85
- const moods = MOOD_MAP[workflow] || ['corporate'];
86
- track = MUSIC_LIBRARY.find(t => t.tags.some(tag => moods.includes(tag))) || MUSIC_LIBRARY[0];
87
- } else {
88
- track = MUSIC_LIBRARY.find(t => t.file === trackName || t.mood === trackName) || MUSIC_LIBRARY[0];
 
 
 
89
  }
90
 
91
- const destPath = path.join(LIB, track.file);
92
 
93
- if (!fs.existsSync(destPath)) {
94
- onProgress?.({ status: 'downloading_music', progress: 10 });
95
- // Try each URL until one works
96
- let downloaded = false;
97
- for (const url of track.urls || []) {
98
- downloaded = await tryDownload(url, destPath);
99
- if (downloaded) break;
 
 
 
 
 
100
  }
101
- if (!downloaded) {
102
- console.warn(`[music] All URLs failed, generating silence for ${track.file}`);
103
- await generateSilence(destPath, duration || 90);
 
 
 
 
 
 
 
 
 
104
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
 
107
- // Verify file has actual audio content
108
- const size = fs.statSync(destPath).size;
 
 
 
 
 
 
109
  if (size < 10000) {
110
- console.warn(`[music] File too small (${size}b), regenerating silence`);
111
- await generateSilence(destPath, duration || 90);
112
  }
 
 
 
 
 
 
 
 
 
113
 
114
- return destPath;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  }
116
 
117
  async function tryDownload(url, dest) {
118
  try {
119
- console.log(`[music] Trying: ${url.slice(0, 60)}`);
120
  const res = await fetch(url, {
121
- headers: {
122
- 'User-Agent': 'Mozilla/5.0 (compatible; HyperFrames/1.0)',
123
- 'Accept': 'audio/mpeg,audio/*,*/*'
124
- },
125
- redirect: 'follow'
126
  });
127
- if (!res.ok) { console.warn(`[music] HTTP ${res.status}`); return false; }
128
- const contentType = res.headers.get('content-type') || '';
129
- if (contentType.includes('text/html')) { console.warn('[music] Got HTML, not audio'); return false; }
130
  await pipeline(res.body, createWriteStream(dest));
131
  const size = fs.existsSync(dest) ? fs.statSync(dest).size : 0;
132
  if (size < 10000) { if (fs.existsSync(dest)) fs.unlinkSync(dest); return false; }
133
- console.log(`[music] Downloaded ${path.basename(dest)} (${(size/1024).toFixed(0)}KB)`);
134
  return true;
135
- } catch (e) {
136
- console.warn(`[music] Download error: ${e.message}`);
137
- if (fs.existsSync(dest)) fs.unlinkSync(dest);
138
- return false;
139
- }
140
  }
141
 
142
  async function generateSilence(destPath, durationSecs) {
143
  try {
144
- // Generate actual silent MP3 (not empty file β€” HyperFrames needs valid audio)
145
  await execFileAsync('ffmpeg', [
146
  '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=stereo',
147
  '-t', String(durationSecs),
148
  '-codec:a', 'libmp3lame', '-b:a', '128k',
149
  destPath, '-y'
150
  ]);
151
- console.log(`[music] Generated ${durationSecs}s silence at ${destPath}`);
152
  } catch (e) {
153
  console.error('[music] ffmpeg silence gen failed:', e.message);
154
  fs.writeFileSync(destPath, Buffer.alloc(0));
@@ -156,5 +201,5 @@ async function generateSilence(destPath, durationSecs) {
156
  }
157
 
158
  export function listTracks() {
159
- return MUSIC_LIBRARY.map(({ file, mood, bpm, tags, license }) => ({ file, mood, bpm, tags, license }));
160
  }
 
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
  const LIB = path.join(__dirname, '..', 'music', 'library');
12
 
13
+ /**
14
+ * Music generation waterfall:
15
+ * 1. ElevenLabs client.music.compose() β€” highest quality lo-fi upbeat
16
+ * 2. YuE via HF Inference API β€” open source fallback
17
+ * 3. ffmpeg silence β€” always available last resort
18
+ */
19
+
20
+ // ElevenLabs prompts by workflow (instrumental, lofi-upbeat style)
21
+ const EL_PROMPTS = {
22
+ testimonial: 'Lo-fi upbeat instrumental, warm jazzy piano chords, soft vinyl crackle, positive motivational energy, subtle hip-hop drums, 88 BPM, no lyrics, royalty-free background music for AI corporate training video',
23
+ teaser: 'Energetic upbeat electronic beat, punchy kick drum, rising synth arpeggios, building tension, 118 BPM, no lyrics, for event teaser video',
24
+ trailer: 'Cinematic orchestral build, emotional string swells, powerful percussion, rising tension to climax, 95 BPM, no lyrics, for brand trailer video',
25
+ community: 'Warm acoustic guitar melody, gentle piano accompaniment, uplifting heartfelt positive, 75 BPM, no lyrics, for community training story video',
26
+ };
27
+
28
+ // YuE prompts (open-source music gen)
29
+ const YUE_PROMPTS = {
30
+ testimonial: '[Genre: Lo-fi Hip Hop] [Mood: Upbeat, Positive, Warm] [Instruments: Jazz Piano, Soft Drums, Bass Guitar] Instrumental background music 88 BPM no lyrics',
31
+ teaser: '[Genre: Electronic] [Mood: Energetic, Rising, Exciting] [Instruments: Synth, Kick Drum, Bass] Instrumental teaser 118 BPM no lyrics',
32
+ trailer: '[Genre: Cinematic Orchestral] [Mood: Epic, Emotional, Building] [Instruments: Strings, Percussion, Brass] Instrumental trailer 95 BPM no lyrics',
33
+ community: '[Genre: Acoustic Folk] [Mood: Warm, Uplifting, Heartfelt] [Instruments: Acoustic Guitar, Piano] Instrumental 75 BPM no lyrics',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  };
35
 
36
  export async function getMusicTrack(trackName, workflow, duration, onProgress) {
37
  fs.mkdirSync(LIB, { recursive: true });
38
 
39
+ const wf = workflow || 'testimonial';
40
+ const cacheFile = path.join(LIB, `${wf}-${duration}s.mp3`);
41
+ const dur = duration || 30;
42
+ const genDur = dur + 6; // always generate 6s extra for safety
43
+
44
+ // Return cached if valid
45
+ if (fs.existsSync(cacheFile) && fs.statSync(cacheFile).size > 50000) {
46
+ console.log(`[music] Using cached: ${path.basename(cacheFile)}`);
47
+ return cacheFile;
48
  }
49
 
50
+ onProgress?.({ status: 'generating_music', progress: 8 });
51
 
52
+ // 1. Try ElevenLabs
53
+ const elKey = process.env.ELEVENLABS_API_KEY;
54
+ if (elKey) {
55
+ try {
56
+ console.log('[music] Generating with ElevenLabs music_v2...');
57
+ const ok = await generateElevenLabsMusic(elKey, wf, genDur, cacheFile);
58
+ if (ok) {
59
+ console.log(`[music] ElevenLabs generated: ${(fs.statSync(cacheFile).size/1024).toFixed(0)}KB`);
60
+ return cacheFile;
61
+ }
62
+ } catch (e) {
63
+ console.warn('[music] ElevenLabs failed:', e.message);
64
  }
65
+ } else {
66
+ console.log('[music] No ELEVENLABS_API_KEY, skipping ElevenLabs');
67
+ }
68
+
69
+ // 2. Try YuE via HF Inference
70
+ const hfToken = process.env.HF_TOKEN;
71
+ try {
72
+ console.log('[music] Trying YuE via HF Inference...');
73
+ const ok = await generateYueMusic(hfToken, wf, genDur, cacheFile);
74
+ if (ok) {
75
+ console.log(`[music] YuE generated: ${(fs.statSync(cacheFile).size/1024).toFixed(0)}KB`);
76
+ return cacheFile;
77
  }
78
+ } catch (e) {
79
+ console.warn('[music] YuE failed:', e.message);
80
+ }
81
+
82
+ // 3. Try bensound URLs
83
+ const bensoundUrls = [
84
+ 'https://www.bensound.com/bensound-music/bensound-ukulele.mp3',
85
+ 'https://www.bensound.com/bensound-music/bensound-happyrock.mp3',
86
+ 'https://www.bensound.com/bensound-music/bensound-slowmotion.mp3',
87
+ ];
88
+ for (const url of bensoundUrls) {
89
+ try {
90
+ const ok = await tryDownload(url, cacheFile);
91
+ if (ok) { console.log('[music] Bensound downloaded'); return cacheFile; }
92
+ } catch (e) { /* continue */ }
93
+ }
94
+
95
+ // 4. ffmpeg silence (always works)
96
+ console.warn('[music] All sources failed, generating silence');
97
+ await generateSilence(cacheFile, genDur);
98
+ return cacheFile;
99
+ }
100
+
101
+ async function generateElevenLabsMusic(apiKey, workflow, durationSecs, destPath) {
102
+ const prompt = EL_PROMPTS[workflow] || EL_PROMPTS.testimonial;
103
+ const musicLengthMs = durationSecs * 1000;
104
+
105
+ const res = await fetch('https://api.elevenlabs.io/v1/music', {
106
+ method: 'POST',
107
+ headers: {
108
+ 'xi-api-key': apiKey,
109
+ 'Content-Type': 'application/json',
110
+ },
111
+ body: JSON.stringify({
112
+ prompt,
113
+ music_length_ms: musicLengthMs,
114
+ model_id: 'music_v2',
115
+ force_instrumental: true,
116
+ }),
117
+ });
118
+
119
+ if (!res.ok) {
120
+ const err = await res.text();
121
+ throw new Error(`ElevenLabs API ${res.status}: ${err.slice(0, 200)}`);
122
  }
123
 
124
+ const contentType = res.headers.get('content-type') || '';
125
+ if (contentType.includes('text/html') || contentType.includes('application/json')) {
126
+ const body = await res.text();
127
+ throw new Error(`ElevenLabs returned non-audio: ${body.slice(0, 200)}`);
128
+ }
129
+
130
+ await pipeline(res.body, createWriteStream(destPath));
131
+ const size = fs.existsSync(destPath) ? fs.statSync(destPath).size : 0;
132
  if (size < 10000) {
133
+ if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
134
+ return false;
135
  }
136
+ return true;
137
+ }
138
+
139
+ async function generateYueMusic(hfToken, workflow, durationSecs, destPath) {
140
+ const prompt = YUE_PROMPTS[workflow] || YUE_PROMPTS.testimonial;
141
+ const headers = {
142
+ 'Content-Type': 'application/json',
143
+ ...(hfToken ? { 'Authorization': `Bearer ${hfToken}` } : {}),
144
+ };
145
 
146
+ // YuE model variants on HF Hub
147
+ const yueEndpoints = [
148
+ 'https://api-inference.huggingface.co/models/m-a-p/YuE-s1-7B-anneal-en-cot',
149
+ 'https://api-inference.huggingface.co/models/m-a-p/YuE-s2-1B-general',
150
+ ];
151
+
152
+ for (const url of yueEndpoints) {
153
+ try {
154
+ const res = await fetch(url, {
155
+ method: 'POST',
156
+ headers,
157
+ body: JSON.stringify({ inputs: prompt, parameters: { max_length: durationSecs * 50 } }),
158
+ signal: AbortSignal.timeout(120000), // 2min timeout
159
+ });
160
+ if (!res.ok) continue;
161
+ const ct = res.headers.get('content-type') || '';
162
+ if (!ct.includes('audio')) continue;
163
+ await pipeline(res.body, createWriteStream(destPath));
164
+ const size = fs.existsSync(destPath) ? fs.statSync(destPath).size : 0;
165
+ if (size > 10000) return true;
166
+ if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
167
+ } catch (e) { /* try next */ }
168
+ }
169
+ return false;
170
  }
171
 
172
  async function tryDownload(url, dest) {
173
  try {
 
174
  const res = await fetch(url, {
175
+ headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': 'audio/mpeg,audio/*,*/*' },
176
+ signal: AbortSignal.timeout(20000),
 
 
 
177
  });
178
+ if (!res.ok) return false;
179
+ const ct = res.headers.get('content-type') || '';
180
+ if (ct.includes('text/html')) return false;
181
  await pipeline(res.body, createWriteStream(dest));
182
  const size = fs.existsSync(dest) ? fs.statSync(dest).size : 0;
183
  if (size < 10000) { if (fs.existsSync(dest)) fs.unlinkSync(dest); return false; }
 
184
  return true;
185
+ } catch (e) { if (fs.existsSync(dest)) fs.unlinkSync(dest); return false; }
 
 
 
 
186
  }
187
 
188
  async function generateSilence(destPath, durationSecs) {
189
  try {
 
190
  await execFileAsync('ffmpeg', [
191
  '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=stereo',
192
  '-t', String(durationSecs),
193
  '-codec:a', 'libmp3lame', '-b:a', '128k',
194
  destPath, '-y'
195
  ]);
196
+ console.log(`[music] Generated ${durationSecs}s silence`);
197
  } catch (e) {
198
  console.error('[music] ffmpeg silence gen failed:', e.message);
199
  fs.writeFileSync(destPath, Buffer.alloc(0));
 
201
  }
202
 
203
  export function listTracks() {
204
+ return [{ file: 'auto', mood: 'auto', bpm: 88, tags: ['lo-fi', 'upbeat', 'instrumental'], license: 'ElevenLabs generated' }];
205
  }
skills/hyperframes-video/SKILL.md CHANGED
@@ -1,225 +1,278 @@
1
- # HyperFrames Production Skill β€” Dee Ferdinand Video Studio
2
- # Sources: nateherkai/hyperframes-student-kit + heygen-com/hyperframes-launch-video + robonuggets/hyperframes-helper
 
 
3
  # Updated: 2026-06-16
4
 
5
  ## THE 5 HARD RULES
 
6
  1. `class="clip"` on EVERY timed element + `data-start` + `data-duration` + `data-track-index`
7
- 2. GSAP: `gsap.timeline({ paused: true })` β†’ registered as `window.__timelines["COMPOSITION_ID"]` (key MUST match `data-composition-id` exactly)
8
- 3. NEVER call `.play()` `.pause()` or set `.currentTime` in scripts β€” framework owns media sync
9
- 4. NEVER use `Math.random()`, `Date.now()`, `fetch()` in GSAP scripts
10
- 5. Always extend: `tl.to({}, { duration: TOTAL }, 0)` β€” prevents black frames at tail
11
 
12
- ## AUDIO β€” CRITICAL (most common failure)
13
 
14
- ### Video audio (subject voice)
15
- ```html
16
- <!-- WRONG: muted blocks audio extraction during render -->
17
- <video muted src="./assets/clip.MOV" data-volume="0.85"></video>
 
 
18
 
19
- <!-- CORRECT: no muted attribute, framework manages sync -->
20
- <video src="./assets/clip.MOV" data-start="8" data-duration="8"
21
- data-track-index="9" data-volume="0.85" playsinline
22
- style="width:100%;height:100%;object-fit:cover;"></video>
23
  ```
24
- **DO NOT ADD `muted` ATTRIBUTE when you want audio in the render.**
25
- HyperFrames strips audio from `muted` elements. The framework controls playback via `data-start`/`data-volume`.
 
 
 
 
26
 
27
- ### Background music
28
- ```html
29
- <audio data-start="0" data-duration="30" data-track-index="50"
30
- data-volume="0.25" src="./assets/music.mp3"></audio>
 
 
 
 
 
31
  ```
32
- NEVER call `.play()` or `.pause()` on audio in scripts.
33
 
34
- ### Audio from HANDOFF.md (heygen production notes)
35
- - `data-start`, `data-duration`, `data-track-index`, `data-volume` all required
36
- - Browser preview may be silent due to autoplay policy β€” click Play in Studio
37
- - Sub-composition timelines must be padded: `tl.to({}, { duration: SLOT_DURATION }, 0)`
38
 
39
- ## DEE FERDINAND BRAND IDENTITY
40
 
41
- ### Fonts (Google Fonts)
42
- ```html
43
- <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  ```
45
- - **Headings / Kinetic titles:** `font-family: 'Space Grotesk', sans-serif` β€” weight 700-800
46
- - **Body / captions / lower thirds:** `font-family: 'Plus Jakarta Sans', sans-serif` β€” weight 400-600
47
 
48
- ### Palette (Dark Premium)
49
- ```css
50
- :root {
51
- --bg: #0d0d1f; /* deep navy-black */
52
- --bg-2: #14142a; /* scene backgrounds */
53
- --accent: #7C6FE0; /* purple accent */
54
- --accent-2: #E06F9A; /* pink accent */
55
- --energy: #9EF0C8; /* energy green for wins */
56
- --text: #ffffff;
57
- --text-dim: rgba(255,255,255,0.7);
58
- --text-muted: rgba(255,255,255,0.5);
59
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  ```
61
 
62
- ### Visual style: Dark Premium (from nateherkai student-kit)
63
- - Deep bg #0d0d1f with radial glow centers
64
- - Space Grotesk 700-900 for display, Plus Jakarta Sans for body
65
- - Purple/pink gradient accents on key moments
66
- - Grain overlay for cinematic texture
67
- - `expo.out` for kinetic titles, `power3.out` for smooth entries, `back.out(1.7)` for stat pops
68
 
69
- ## CAPTION SIZES β€” 2X KINETIC (Dee's testimonial style)
70
 
71
- **Testimonial video is KINETIC style = BIG, BOLD, MOVING**
 
 
 
 
 
 
 
 
 
72
 
73
  | Element | Size | Weight | Font |
74
  |---------|------|--------|------|
75
- | Hook main | **160px** | 900 | Space Grotesk |
76
- | Hook sub | **88px** | 700 | Plus Jakarta Sans |
77
- | Stat counter | **140px** | 900 | Space Grotesk |
78
- | Energy cuts | **128px** | 900 | Space Grotesk |
 
79
  | CTA main | **112px** | 900 | Space Grotesk |
80
- | Testimonial quote | **72px** | 600 | Plus Jakarta Sans italic |
81
  | Lower third title | **40px** | 700 | Plus Jakarta Sans |
82
- | Lower third sub | **24px** | 400 | Plus Jakarta Sans |
83
  | Brand end name | **80px** | 800 | Space Grotesk |
84
- | Brand end tag | **40px** | 500 | Plus Jakarta Sans |
85
- | Brand bar persistent | **26px** | 700 | Space Grotesk |
86
-
87
- ## KINETIC MOTION PATTERNS
88
-
89
- From motion-principles.md (nateherkai student-kit):
90
- - Vary eases per scene β€” never same ease twice in a row
91
- - Speed: 0.15-0.25s = energy/urgency, 0.3-0.5s = professional, 0.6-1.0s = cinematic
92
- - Direction variety: `y: -60` (from above), `x: -80` (from left), `scale: 0.7` (scale in), `autoAlpha` only (no translate)
93
- - Stagger text: multiple spans/divs, `{ stagger: 0.08 }` for word-by-word feel
94
- - Build/breathe/resolve: stagger in β†’ ambient drift β†’ fast exit
95
-
96
- ### Kinetic title entry patterns (vary per scene)
97
- ```javascript
98
- // Pattern A: slam from above (hook)
99
- tl.from("#title", { y: -80, autoAlpha: 0, duration: 0.25, ease: "expo.out" }, 0.2);
100
 
101
- // Pattern B: scale pop (stat)
102
- tl.from("#stat", { scale: 0.6, autoAlpha: 0, duration: 0.4, ease: "back.out(2.5)" }, 0.3);
103
 
104
- // Pattern C: slide from left (energy)
105
- tl.from("#cap", { x: -100, autoAlpha: 0, duration: 0.3, ease: "power4.out" }, 0.2);
 
 
 
 
 
 
 
 
 
106
 
107
- // Pattern D: letter stagger (CTA)
108
- tl.from(".word", { y: 60, autoAlpha: 0, duration: 0.35, stagger: 0.08, ease: "power3.out" }, 0.2);
109
 
110
- // Pattern E: opacity only (testimonial quote β€” calm contrast)
111
- tl.from("#quote", { autoAlpha: 0, duration: 0.6, ease: "sine.inOut" }, 0.5);
112
- ```
113
 
114
- ### Transition patterns (from aisoc-hype + launch-video)
115
- ```javascript
116
- // Zoom-through (dramatic, between big scenes)
117
- tl.to("#s1", { autoAlpha: 0, scale: 1.25, duration: 0.35, ease: "power4.inOut" }, 3.95);
118
- tl.fromTo("#s2", { autoAlpha: 0, scale: 0.8 }, { autoAlpha: 1, scale: 1, duration: 0.35, ease: "power4.inOut" }, 3.95);
 
 
 
119
 
120
- // Push-slide left (editorial, between cuts)
121
- tl.to("#s1", { autoAlpha: 0, xPercent: -18, duration: 0.3, ease: "power2.inOut" }, 3.95);
122
- tl.fromTo("#s2", { autoAlpha: 0, xPercent: 18 }, { autoAlpha: 1, xPercent: 0, duration: 0.3, ease: "power2.inOut" }, 3.95);
 
 
 
123
 
124
- // Blur crossfade (wind-down, into CTA)
125
- tl.to("#s4", { autoAlpha: 0, filter: "blur(16px)", scale: 1.05, duration: 0.6, ease: "sine.inOut" }, 15.8);
126
- tl.fromTo("#s5", { autoAlpha: 0, filter: "blur(16px)", scale: 0.95 }, { autoAlpha: 1, filter: "blur(0px)", scale: 1, duration: 0.6, ease: "sine.inOut" }, 15.8);
127
 
128
- // Hard cut (energy/disruption) β€” just swap data-start with 0 overlap
129
- ```
130
 
131
- ## GLASS CARD RECIPE (from robonuggets)
132
- ```css
133
- .glass {
134
- background: linear-gradient(135deg, rgba(255,255,255,0.14) 0%, rgba(255,255,255,0.04) 50%, rgba(255,255,255,0.08) 100%),
135
- rgba(20,22,30,0.28);
136
- border: 1px solid rgba(255,255,255,0.18);
137
- border-radius: 22px;
138
- backdrop-filter: blur(24px) saturate(160%);
139
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.22), 0 16px 48px -12px rgba(0,0,0,0.5);
140
- }
 
 
 
 
 
 
 
 
141
  ```
142
 
143
- ## GRAIN OVERLAY (cinematic texture)
144
- ```html
145
- <div id="grain" style="position:absolute;inset:0;pointer-events:none;z-index:99;overflow:hidden;">
146
- <div style="position:absolute;top:-50%;left:-50%;width:200%;height:200%;opacity:0.06;
147
- background:url('data:image/svg+xml,%3Csvg viewBox=\'0 0 256 256\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'n\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23n)\'/%3E%3C/svg%3E');
148
- animation:grain 0.5s steps(1) infinite;"></div>
149
- </div>
150
- <style>@keyframes grain{0%,100%{transform:translate(0,0)}10%{transform:translate(-5%,-5%)}20%{transform:translate(-10%,5%)}30%{transform:translate(5%,-10%)}50%{transform:translate(-10%,5%)}70%{transform:translate(0,10%)}90%{transform:translate(10%,5%)}}</style>
151
- ```
152
 
153
- ## LOWER THIRD (glass card style)
154
  ```html
155
- <div id="lt" class="clip" data-start="4.6" data-duration="3.0" data-track-index="7"
156
  style="position:absolute;bottom:180px;left:0;padding:0 0 0 56px;">
157
- <div style="background:rgba(10,10,20,0.80);backdrop-filter:blur(16px) saturate(140%);
158
- border-left:5px solid #7C6FE0;border-radius:0 14px 14px 0;
159
- padding:16px 28px;display:inline-block;">
160
  <div style="font:700 40px/1.2 'Plus Jakarta Sans',sans-serif;color:#fff;">Purbasari Indonesia</div>
161
- <div style="font:400 24px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.65);
162
- margin-top:6px;">AI Training Β· Juni 2026</div>
163
  </div>
164
  </div>
165
  ```
166
 
167
- ## WORKING MUSIC URLS (server-accessible)
168
-
169
- Use bensound.com free tier or freesound direct links β€” avoid FMA (404/503):
170
-
171
  ```javascript
172
- // In music.js β€” use these as fallback chain:
173
- const TRACKS = [
174
- // Try 1: bensound (free tier, requires attribution in metadata only)
175
- 'https://www.bensound.com/bensound-music/bensound-ukulele.mp3',
176
- // Try 2: ccMixter direct
177
- 'https://dig.ccmixter.org/files/airtone/56796/airtone-_retro_.mp3',
178
- // Fallback: generate silence with ffmpeg (already implemented)
179
- ];
180
  ```
181
 
182
- ## COMPLETE CORRECT STRUCTURE (9:16 Β· 1080Γ—1920)
183
 
184
  ```html
185
  <!DOCTYPE html>
186
  <html><head><meta charset="utf-8">
187
- <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@700;900&family=Plus+Jakarta+Sans:wght@400;600;700&display=swap" rel="stylesheet">
188
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
189
- <style>
190
- * { margin:0;padding:0;box-sizing:border-box; }
191
- body { background:#0d0d1f;overflow:hidden; }
192
- #root { position:relative;width:1080px;height:1920px;overflow:hidden;background:#0d0d1f; }
193
- .sc { position:absolute;inset:0; } /* scene base */
194
  </style></head><body>
195
- <div id="root" data-composition-id="COMP_ID" data-start="0" data-width="1080" data-height="1920">
196
-
197
- <!-- Scene: always wrap video in div, never animate video directly -->
 
 
 
 
 
198
  <div id="s1" class="clip sc" data-start="0" data-duration="4" data-track-index="0">
199
- <div id="s1vw" style="position:absolute;inset:0">
200
- <!-- NO muted ATTRIBUTE when you want subject audio -->
201
- <video data-start="0" data-duration="4" data-track-index="1" data-volume="0.9"
202
- src="./assets/clip.MOV" playsinline
203
  style="width:100%;height:100%;object-fit:cover;"></video>
204
  </div>
205
  </div>
206
 
207
- <!-- Caption: class=clip is mandatory -->
208
- <div id="cap" class="clip" data-start="0.3" data-duration="3.5" data-track-index="2"
209
- style="position:absolute;bottom:400px;left:0;right:0;padding:0 56px;text-align:center;
210
- font:900 160px/1.05 'Space Grotesk',sans-serif;color:#fff;
211
- text-shadow:0 4px 32px rgba(0,0,0,0.9);">Big Title</div>
212
 
213
- <!-- Music: no muted, no script control -->
214
  <audio data-start="0" data-duration="30" data-track-index="50"
215
- data-volume="0.25" src="./assets/music.mp3"></audio>
216
 
217
  <script>
218
  const tl = gsap.timeline({ paused: true });
219
- tl.from("#cap", { y: -80, autoAlpha: 0, duration: 0.25, ease: "expo.out" }, 0.3);
220
- // CRITICAL: extend to prevent black frames
221
- tl.to({}, { duration: 30 }, 0);
222
- // CRITICAL: key must match data-composition-id exactly
 
 
 
 
223
  window.__timelines = window.__timelines || {};
224
  window.__timelines["COMP_ID"] = tl;
225
  </script>
@@ -227,6 +280,16 @@ body { background:#0d0d1f;overflow:hidden; }
227
  ```
228
 
229
  ## RENDER COMMAND
 
230
  ```bash
231
- PRODUCER_HEADLESS_SHELL_PATH=/usr/bin/chromium npx hyperframes render . -o output.mp4 -w 1 --fps 30 --quality standard
 
232
  ```
 
 
 
 
 
 
 
 
 
1
+ # HyperFrames Video Production Skill β€” Dee Ferdinand Video Studio
2
+ # Sources: nateherkai/hyperframes-student-kit + heygen-com/hyperframes-launch-video
3
+ # robonuggets/hyperframes-helper + coleam00/hyperframes-ai-video-generation
4
+ # elevenlabs/skills
5
  # Updated: 2026-06-16
6
 
7
  ## THE 5 HARD RULES
8
+
9
  1. `class="clip"` on EVERY timed element + `data-start` + `data-duration` + `data-track-index`
10
+ 2. GSAP: `gsap.timeline({ paused: true })` β†’ `window.__timelines["COMP_ID"] = tl` (key MUST match `data-composition-id`)
11
+ 3. NEVER call `.play()` `.pause()` or set `.currentTime` in scripts β€” framework owns media
12
+ 4. NEVER `Math.random()`, `Date.now()`, `fetch()` in GSAP scripts (breaks determinism)
13
+ 5. Extend timeline: `tl.set({}, {}, TOTAL_DURATION)` β€” prevents render cutoff and black frames
14
 
15
+ ## BLACK FRAME PREVENTION (from heygen HANDOFF.md + coleam00 transitions.md)
16
 
17
+ **Root causes of black frames:**
18
+ - Timeline shorter than expected: always `tl.set({}, {}, TOTAL_DURATION)` at the very end
19
+ - Scene overlap on same track: clips on the same `data-track-index` CANNOT overlap even 0.001s
20
+ - Exit animations BANNED (except final scene): do NOT `gsap.to()` elements out before transition
21
+ The transition IS the exit. Outgoing content must be fully visible when transition fires.
22
+ - Scene gap: next scene `data-start` must not exceed current `data-start + data-duration`
23
 
24
+ **Correct scene timing pattern (no gaps, no overlap):**
 
 
 
25
  ```
26
+ Scene 1: data-start="0" data-duration="4" β†’ ends at 4.0s
27
+ Scene 2: data-start="3.65" data-duration="4" β†’ starts at 3.65s (0.35s overlap for transition)
28
+ Scene 3: data-start="7.65" data-duration="8" β†’ starts at 7.65s
29
+ ```
30
+ Scenes on DIFFERENT track-indices can overlap (that's how transitions work).
31
+ Scenes on the SAME track-index must NEVER overlap.
32
 
33
+ ## AUDIO (from coleam00 audio-design.md β€” research-backed volumes)
34
+
35
+ ```
36
+ data-volume levels:
37
+ Subject voice (testimonial video): 0.88 (β‰ˆ -14 dB peak)
38
+ Music during energy/CTA: 0.12 (β‰ˆ -15 to -18 dB)
39
+ Music during testimonial video: 0.07 (β‰ˆ -20 to -23 dB)
40
+ Music general background: 0.10
41
+ Narration/voiceover: 1.0 (β‰ˆ -12 dB peak)
42
  ```
 
43
 
44
+ **NEVER** use lyrical music under speech (proven comprehension harm).
45
+ **ALWAYS** use `force_instrumental: true` on ElevenLabs music API.
46
+ **NEVER** add `muted` attribute to video when subject voice is needed.
 
47
 
48
+ ## ELEVENLABS MUSIC GENERATION (primary)
49
 
50
+ ```python
51
+ # generate_music.py β€” generates lo-fi upbeat track for Dee Ferdinand videos
52
+ from elevenlabs import ElevenLabs
53
+ import os
54
+
55
+ client = ElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
56
+
57
+ audio = client.music.compose(
58
+ prompt="Lo-fi upbeat instrumental, warm jazzy piano chords, soft vinyl crackle, "
59
+ "positive motivational energy, subtle hip-hop drums, 88 BPM, "
60
+ "no lyrics, background music for AI corporate training video",
61
+ music_length_ms=35000, # always generate 5s extra beyond video duration
62
+ model_id="music_v2",
63
+ force_instrumental=True,
64
+ )
65
+
66
+ with open("./assets/music.mp3", "wb") as f:
67
+ for chunk in audio:
68
+ f.write(chunk)
69
  ```
 
 
70
 
71
+ **Prompt templates by workflow:**
72
+
73
+ | Workflow | ElevenLabs prompt |
74
+ |----------|-------------------|
75
+ | testimonial | "Lo-fi upbeat instrumental, warm jazzy piano, vinyl crackle, positive energy, 88 BPM, no lyrics, for AI corporate training" |
76
+ | teaser | "Energetic upbeat electronic, punchy kick drum, rising synths, 120 BPM, no lyrics, for event teaser" |
77
+ | trailer | "Cinematic orchestral build, emotional strings, powerful swells, 90 BPM, no lyrics, for brand trailer" |
78
+ | community | "Warm acoustic guitar, gentle piano, uplifting and heartfelt, 75 BPM, no lyrics, for community story" |
79
+
80
+ **Requires:** `ELEVENLABS_API_KEY` env var in HF Space secrets (Settings β†’ Variables).
81
+
82
+ ## YUE FALLBACK (when ElevenLabs key not available)
83
+
84
+ ```python
85
+ # YuE: open-source music generation from multimodal-art-projection
86
+ # GitHub: https://github.com/multimodal-art-projection/YuE
87
+ # Use via Hugging Face Inference API (no local GPU needed)
88
+ import requests, os
89
+
90
+ def generate_music_yue(prompt: str, duration_seconds: int = 35) -> bytes:
91
+ """
92
+ YuE music generation via HF Inference API.
93
+ Falls back to this when ELEVENLABS_API_KEY is not set.
94
+ """
95
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
96
+ headers = {"Authorization": f"Bearer {HF_TOKEN}"} if HF_TOKEN else {}
97
+
98
+ # YuE model on HF Hub
99
+ url = "https://api-inference.huggingface.co/models/m-a-p/YuE-s1-7B-anneal-en-cot"
100
+
101
+ payload = {
102
+ "inputs": prompt,
103
+ "parameters": {
104
+ "max_length": duration_seconds * 50, # approximate token count
105
+ "num_return_sequences": 1,
106
+ }
107
+ }
108
+
109
+ r = requests.post(url, headers=headers, json=payload, timeout=120)
110
+ if r.status_code == 200:
111
+ return r.content # audio bytes
112
+ raise Exception(f"YuE generation failed: {r.status_code} {r.text[:200]}")
113
  ```
114
 
115
+ **YuE prompt format:**
116
+ ```
117
+ [Genre: Lo-fi Hip Hop] [Mood: Upbeat, Positive] [Instruments: Piano, Drums, Bass]
118
+ Background instrumental music for corporate AI training video. No lyrics. 88 BPM.
119
+ ```
 
120
 
121
+ ## DEE FERDINAND BRAND FONTS
122
 
123
+ ```html
124
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700;900
125
+ &family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,600
126
+ &display=swap" rel="stylesheet">
127
+ ```
128
+
129
+ - **ALL display titles / kinetic captions:** `font: 900 Xpx 'Space Grotesk', sans-serif`
130
+ - **Body / quotes / lower thirds / UI text:** `font: 600 Xpx 'Plus Jakarta Sans', sans-serif`
131
+
132
+ ## CAPTION SIZES β€” 2X KINETIC (Dee's style)
133
 
134
  | Element | Size | Weight | Font |
135
  |---------|------|--------|------|
136
+ | Hook line 1 | **160px** | 900 | Space Grotesk |
137
+ | Hook line 2 (accent color) | **160px** | 900 | Space Grotesk |
138
+ | Hook sub | **72px** | 700 | Plus Jakarta Sans |
139
+ | Stat counter | **160px** | 900 | Space Grotesk |
140
+ | Energy cut captions | **128-144px** | 900 | Space Grotesk |
141
  | CTA main | **112px** | 900 | Space Grotesk |
142
+ | Testimonial quote | **72px italic** | 600 | Plus Jakarta Sans |
143
  | Lower third title | **40px** | 700 | Plus Jakarta Sans |
144
+ | Lower third subtitle | **24px** | 400 | Plus Jakarta Sans |
145
  | Brand end name | **80px** | 800 | Space Grotesk |
146
+ | Brand end tagline | **40px** | 500 | Plus Jakarta Sans |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
+ ## DARK PREMIUM PALETTE
 
149
 
150
+ ```css
151
+ :root {
152
+ --bg: #0d0d1f; /* deep navy-black canvas */
153
+ --accent: #7C6FE0; /* purple accent */
154
+ --accent-2: #E06F9A; /* pink accent */
155
+ --energy: #9EF0C8; /* teal-green for wins/energy */
156
+ --text: #ffffff;
157
+ --text-dim: rgba(255,255,255,0.72);
158
+ --text-muted: rgba(255,255,255,0.48);
159
+ }
160
+ ```
161
 
162
+ ## MOTION VOCABULARY (from nateherkai motion-principles.md)
 
163
 
164
+ Vary eases β€” NEVER use same ease twice in a row.
 
 
165
 
166
+ | Moment | GSAP ease | Duration | Feel |
167
+ |--------|-----------|----------|------|
168
+ | Hook title slam | `expo.out` | 0.20-0.25s | Kinetic energy |
169
+ | Stat pop | `back.out(2.5)` | 0.40-0.50s | Bouncy confidence |
170
+ | Energy cut caption | `power4.out` | 0.22-0.28s | Fast/decisive |
171
+ | CTA slide up | `power3.out` | 0.45-0.55s | Professional |
172
+ | Testimonial quote | `sine.inOut` | 0.55-0.65s | Calm contrast |
173
+ | Brand end stagger | `power3.out` | 0.35-0.45s | Composed |
174
 
175
+ **Direction variety (never all the same):**
176
+ - Slam from above: `y: -100`
177
+ - Slide from left: `x: -120`
178
+ - Slide from right: `x: 120`
179
+ - Scale pop: `scale: 0.55`
180
+ - Opacity only: `autoAlpha: 0` (no translate) β€” for calm moments
181
 
182
+ ## TRANSITION PATTERNS (from coleam00 transitions.md)
 
 
183
 
184
+ Rule: EXIT ANIMATIONS ARE BANNED except on final scene. The transition IS the exit.
185
+ Incoming scene's entrance fires at transition midpoint, outgoing scene fades simultaneously.
186
 
187
+ ```javascript
188
+ // Zoom-through (dramatic opener, hook→2)
189
+ const T1 = 3.65; // transition start
190
+ tl.to("#s1", { autoAlpha: 0, scale: 1.3, duration: 0.35, ease: "power4.inOut" }, T1);
191
+ tl.fromTo("#s2", { autoAlpha: 0, scale: 0.8 }, { autoAlpha: 1, scale: 1, duration: 0.35, ease: "power4.inOut" }, T1);
192
+
193
+ // Push-slide left (editorial, proof→3)
194
+ const T2 = 7.65;
195
+ tl.to("#s2", { autoAlpha: 0, xPercent: -18, duration: 0.32, ease: "power2.inOut" }, T2);
196
+ tl.fromTo("#s3", { autoAlpha: 0, xPercent: 18 }, { autoAlpha: 1, xPercent: 0, duration: 0.32, ease: "power2.inOut" }, T2);
197
+
198
+ // Blur crossfade (wind-down, testimonial→4)
199
+ const T3 = 15.65;
200
+ tl.to("#s3", { autoAlpha: 0, filter: "blur(14px)", scale: 1.04, duration: 0.45, ease: "sine.inOut" }, T3);
201
+ tl.fromTo("#s4a", { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.25 }, T3 + 0.2);
202
+
203
+ // Hard cut (energy B-roll β€” disruption intentional)
204
+ // Just stagger data-start values with NO overlap (different tracks ok)
205
  ```
206
 
207
+ ## GLASS CARD LOWER THIRD (robonuggets recipe)
 
 
 
 
 
 
 
 
208
 
 
209
  ```html
210
+ <div id="lt" class="clip" data-start="4.6" data-duration="2.9" data-track-index="8"
211
  style="position:absolute;bottom:180px;left:0;padding:0 0 0 56px;">
212
+ <div style="background:rgba(10,10,25,0.85);backdrop-filter:blur(20px) saturate(150%);
213
+ border-left:6px solid #7C6FE0;border-radius:0 16px 16px 0;
214
+ padding:18px 32px;display:inline-block;">
215
  <div style="font:700 40px/1.2 'Plus Jakarta Sans',sans-serif;color:#fff;">Purbasari Indonesia</div>
216
+ <div style="font:400 24px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.62);
217
+ margin-top:6px;">AI Training Β· 2026</div>
218
  </div>
219
  </div>
220
  ```
221
 
 
 
 
 
222
  ```javascript
223
+ // Slide in from left, auto-exit via next transition (NOT gsap.to exit)
224
+ tl.from("#lt", { x: -400, autoAlpha: 0, duration: 0.4, ease: "power3.out" }, 4.6);
225
+ // NO exit tween β€” lt will be covered by next scene transition
 
 
 
 
 
226
  ```
227
 
228
+ ## COMPLETE CORRECT TEMPLATE
229
 
230
  ```html
231
  <!DOCTYPE html>
232
  <html><head><meta charset="utf-8">
233
+ <link href="[Google Fonts Space Grotesk + Plus Jakarta Sans]" rel="stylesheet">
234
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
235
+ <style>* {margin:0;padding:0;box-sizing:border-box} body {background:#0d0d1f;overflow:hidden}
236
+ #root {position:relative;width:1080px;height:1920px;overflow:hidden;background:#0d0d1f}
237
+ .sc {position:absolute;inset:0}
 
 
238
  </style></head><body>
239
+ <div id="root"
240
+ data-composition-id="COMP_ID"
241
+ data-start="0"
242
+ data-duration="30"
243
+ data-width="1080"
244
+ data-height="1920">
245
+
246
+ <!-- Scene: timed wrapper -->
247
  <div id="s1" class="clip sc" data-start="0" data-duration="4" data-track-index="0">
248
+ <!-- Video: NO muted for subject voice. Wrap in div. -->
249
+ <div style="position:absolute;inset:0">
250
+ <video data-start="0" data-duration="4" data-track-index="1"
251
+ data-volume="0.88" src="./assets/clip.MOV" playsinline
252
  style="width:100%;height:100%;object-fit:cover;"></video>
253
  </div>
254
  </div>
255
 
256
+ <!-- Caption: class=clip mandatory -->
257
+ <div id="cap" class="clip" data-start="0.2" data-duration="3.5" data-track-index="2"
258
+ style="position:absolute;bottom:400px;left:0;right:0;padding:0 56px;
259
+ text-align:center;font:900 160px/1.0 'Space Grotesk',sans-serif;
260
+ color:#fff;text-shadow:0 6px 40px rgba(0,0,0,0.95);">Hook</div>
261
 
262
+ <!-- Music: instrumental, force_instrumental generated by ElevenLabs -->
263
  <audio data-start="0" data-duration="30" data-track-index="50"
264
+ data-volume="0.10" src="./assets/music.mp3"></audio>
265
 
266
  <script>
267
  const tl = gsap.timeline({ paused: true });
268
+
269
+ // Entrance (varied eases, never same twice)
270
+ tl.from("#cap", { y: -100, autoAlpha: 0, duration: 0.22, ease: "expo.out" }, 0.2);
271
+
272
+ // CRITICAL: extend to prevent cutoff / black frames
273
+ tl.set({}, {}, 30); // use set not to()
274
+
275
+ // CRITICAL: key = data-composition-id exactly
276
  window.__timelines = window.__timelines || {};
277
  window.__timelines["COMP_ID"] = tl;
278
  </script>
 
280
  ```
281
 
282
  ## RENDER COMMAND
283
+
284
  ```bash
285
+ PRODUCER_HEADLESS_SHELL_PATH=/usr/bin/chromium \
286
+ npx hyperframes render . -o ./renders/output.mp4 -w 1 --fps 30 --quality standard
287
  ```
288
+
289
+ ## MUSIC GENERATION WATERFALL
290
+
291
+ 1. **ElevenLabs** (`client.music.compose()`) β€” highest quality, requires API key
292
+ 2. **YuE via HF Inference** β€” open source, requires HF_TOKEN, slower
293
+ 3. **ffmpeg silence fallback** β€” always available, no music
294
+
295
+ Set `ELEVENLABS_API_KEY` in HF Space secrets for production quality music.
workflows/index.js CHANGED
@@ -1,280 +1,264 @@
1
  // workflows/index.js β€” Dee Ferdinand Video Studio
2
- // Correct HyperFrames compositions with:
3
- // - Space Grotesk (headings) + Plus Jakarta Sans (body)
4
- // - 2x kinetic caption sizes
5
- // - Varied GSAP motion (expo.out hooks, back.out stats, power4 energy, sine CTA)
6
- // - Varied transitions (zoom-through, push-slide, blur crossfade)
7
- // - NO muted on video elements (audio extraction fix)
8
- // - tl.to({},{duration:TOTAL},0) prevents black frames
9
-
10
- function mediaEl(m, start, duration, trackIdx, volume = 0) {
11
- if (!m) return '<div style="position:absolute;inset:0;background:#0d0d1f;"></div>';
12
- if (m.mime?.startsWith('video/')) {
13
- // CRITICAL: no muted attribute when volume > 0 β€” muted blocks audio extraction
14
- return `<div id="vw-${trackIdx}" style="position:absolute;inset:0">
15
- <video data-start="${start}" data-duration="${duration}" data-track-index="${trackIdx}"
16
- data-volume="${volume}" src="${m.rel}" playsinline
17
- style="width:100%;height:100%;object-fit:cover;"></video>
18
- </div>`;
19
- }
20
- return `<img src="${m.rel}" alt="" style="width:100%;height:100%;object-fit:cover;transform-origin:center center;">`;
21
- }
22
 
23
- const FONTS = `<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700;900&family=Plus+Jakarta+Sans:ital,wght@0,400;0,600;0,700;0,800;1,400;1,600&display=swap" rel="stylesheet">`;
24
- const GSAP_CDN = `<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>`;
25
- const BASE_CSS = `
26
- * { margin:0;padding:0;box-sizing:border-box; }
27
- body { background:#0d0d1f;overflow:hidden; }
28
- #root { position:relative;width:VAR_WPXpx;height:VAR_HPXpx;overflow:hidden;background:#0d0d1f; }
29
- .sc { position:absolute;inset:0; }
30
- @keyframes grain{0%,100%{transform:translate(0,0)}10%{transform:translate(-5%,-5%)}20%{transform:translate(-10%,5%)}30%{transform:translate(5%,-10%)}50%{transform:translate(-10%,5%)}70%{transform:translate(0,10%)}90%{transform:translate(10%,5%)}}
31
- `;
32
-
33
- const GRAIN = `<div id="grain" style="position:absolute;inset:0;pointer-events:none;z-index:98;overflow:hidden;">
34
  <div style="position:absolute;top:-50%;left:-50%;width:200%;height:200%;opacity:0.065;
35
  background:url('data:image/svg+xml,%3Csvg viewBox=\'0 0 256 256\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'n\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23n)\'/%3E%3C/svg%3E');
36
  animation:grain 0.5s steps(1) infinite;"></div>
37
  </div>`;
38
 
39
- // ── TESTIMONIAL 30s ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  const testimonial = {
41
- meta: {
42
- name: 'Corporate Testimonial', icon: '\uD83C\uDFE2',
43
- description: '30s kinetic testimonial. 9:16. Space Grotesk + Plus Jakarta Sans. 2x sizes.',
44
- format: '9:16', duration: 30, music: 'corporate',
45
- },
46
 
47
  buildHTML(media, config, compId, w, h, dur) {
48
- const { clientName = 'AI Training', trainerName = 'Dee Ferdinand',
49
- tagline = 'AI Corporate Trainer', website = 'deeferdinand.com' } = config;
50
  const vids = media.filter(m => m.mime?.startsWith('video/'));
51
  const imgs = media.filter(m => m.mime?.startsWith('image/'));
52
- const pick = (i) => media[i % Math.max(media.length, 1)] || media[0];
53
- const mVid = vids[0] || null; // testimonial video β€” use for scene 3
54
- const mImg = (i) => imgs[i % Math.max(imgs.length, 1)] || pick(i);
55
- const year = new Date().getFullYear();
56
- const css = BASE_CSS.replace('VAR_WPX', w).replace('VAR_HPX', h);
 
 
 
 
 
57
 
58
  return `<!DOCTYPE html>
59
- <html><head><meta charset="utf-8">${FONTS}${GSAP_CDN}
60
- <style>${css}
61
- /* Radial glow centers for depth */
62
- .glow-1 { position:absolute;width:600px;height:600px;border-radius:50%;
63
- background:radial-gradient(ellipse,rgba(124,111,224,0.18) 0%,transparent 70%);
64
- top:10%;left:50%;transform:translateX(-50%);pointer-events:none;z-index:0; }
65
- .glow-2 { position:absolute;width:500px;height:500px;border-radius:50%;
66
- background:radial-gradient(ellipse,rgba(224,111,154,0.14) 0%,transparent 70%);
67
- bottom:15%;right:-100px;pointer-events:none;z-index:0; }
68
  </style></head><body>
69
- <div id="root" data-composition-id="${compId}" data-start="0" data-width="${w}" data-height="${h}">
70
 
71
- <!-- Persistent background depth glows -->
72
- <div class="glow-1"></div>
73
- <div class="glow-2"></div>
 
 
74
 
75
- <!-- ─── SCENE 1: HOOK 0–4s ──────────────────────────────── -->
76
  <div id="s1" class="clip sc" data-start="0" data-duration="4" data-track-index="0">
77
- ${mediaEl(mImg(0), 0, 4, 1, 0)}
78
- <div style="position:absolute;inset:0;background:rgba(0,0,0,0.42);"></div>
79
  </div>
80
- <!-- Hook: 2 lines, slam from above β€”expo.outβ€” kinetic energy -->
81
- <div id="cap-h1" class="clip" data-start="0.2" data-duration="3.5" data-track-index="2"
82
- style="position:absolute;bottom:580px;left:0;right:0;padding:0 56px;
83
- text-align:center;font:900 160px/1.0 'Space Grotesk',sans-serif;color:#fff;
84
- text-shadow:0 6px 40px rgba(0,0,0,0.95);letter-spacing:-3px;">Ini yang</div>
85
- <div id="cap-h2" class="clip" data-start="0.35" data-duration="3.3" data-track-index="3"
86
- style="position:absolute;bottom:380px;left:0;right:0;padding:0 56px;
87
- text-align:center;font:900 160px/1.0 'Space Grotesk',sans-serif;
88
- color:#7C6FE0;text-shadow:0 6px 40px rgba(0,0,0,0.9);letter-spacing:-3px;">terjadi</div>
89
- <div id="cap-h3" class="clip" data-start="0.5" data-duration="3.1" data-track-index="4"
90
- style="position:absolute;bottom:260px;left:0;right:0;padding:0 56px;
91
- text-align:center;font:700 72px/1.2 'Plus Jakarta Sans',sans-serif;
92
- color:rgba(255,255,255,0.80);">ketika tim belajar AI</div>
93
-
94
- <!-- ─── SCENE 2: PROOF 4–8s ─────────────────────────────── -->
95
- <div id="s2" class="clip sc" data-start="4" data-duration="4" data-track-index="5">
96
- ${mediaEl(mImg(1), 4, 4, 6, 0)}
97
- <div style="position:absolute;inset:0;background:rgba(0,0,0,0.55);"></div>
98
  </div>
99
- <!-- Stat: scale pop β€”back.outβ€” maximum impact -->
100
- <div id="cap-stat" class="clip" data-start="4.3" data-duration="3.4" data-track-index="7"
101
- style="position:absolute;top:50%;left:0;right:0;transform:translateY(-55%);
102
- text-align:center;color:#fff;">
103
  <div style="font:900 160px/1.0 'Space Grotesk',sans-serif;letter-spacing:-4px;
104
- text-shadow:0 6px 40px rgba(0,0,0,0.9);">1,000+</div>
105
- <div style="font:600 48px/1.2 'Plus Jakarta Sans',sans-serif;opacity:0.85;
106
- margin-top:16px;">profesional terlatih</div>
107
- <div style="font:400 30px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.55);
108
- margin-top:10px;">sejak 2023</div>
109
  </div>
110
- <!-- Lower third: glass card, slides from left -->
111
- <div id="lt" class="clip" data-start="4.6" data-duration="3.0" data-track-index="8"
112
- style="position:absolute;bottom:180px;left:0;padding:0 0 0 56px;">
113
  <div style="background:rgba(10,10,25,0.85);backdrop-filter:blur(20px) saturate(150%);
114
- border-left:6px solid #7C6FE0;border-radius:0 16px 16px 0;
115
- padding:18px 32px;display:inline-block;">
116
  <div style="font:700 40px/1.2 'Plus Jakarta Sans',sans-serif;color:#fff;">${clientName}</div>
117
- <div style="font:400 24px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.62);
118
- margin-top:6px;">AI Training \u00b7 ${year}</div>
119
  </div>
120
  </div>
121
 
122
- <!-- ─── SCENE 3: TESTIMONIAL 8–16s ──────────────────────── -->
123
- <div id="s3" class="clip sc" data-start="8" data-duration="8" data-track-index="9">
124
  ${mVid
125
- ? `<div style="position:absolute;inset:0;">
126
- <!-- NO muted: subject voice audio needs to come through -->
127
- <video data-start="8" data-duration="8" data-track-index="10" data-volume="0.88"
128
- src="${mVid.rel}" playsinline
129
- style="width:100%;height:100%;object-fit:cover;"></video>
130
- </div>`
131
- : `${mediaEl(mImg(2), 8, 8, 10, 0)}`
132
- }
133
- <!-- Gradient for caption readability -->
134
  <div style="position:absolute;bottom:0;left:0;right:0;height:500px;
135
- background:linear-gradient(transparent,rgba(0,0,0,0.80));"></div>
136
  </div>
137
- <!-- Quote: opacity only β€”sine.inOutβ€” calm contrast to kinetic scenes -->
138
- <div id="cap-quote" class="clip" data-start="8.6" data-duration="7.1" data-track-index="11"
139
- style="position:absolute;bottom:240px;left:0;right:0;padding:0 56px;
140
- text-align:center;font:600 72px/1.3 'Plus Jakarta Sans',sans-serif;
141
- color:#fff;text-shadow:0 4px 24px rgba(0,0,0,0.95);
142
- font-style:italic;">
143
  &ldquo;Saya pikir AI susah.<br>Ternyata langsung bisa!&rdquo;
144
  </div>
145
 
146
- <!-- ─── SCENE 4a: ENERGY CUT 1 β€” 16–18.5s ───────────────── -->
147
- <div id="s4a" class="clip sc" data-start="16" data-duration="2.5" data-track-index="12">
148
- ${mediaEl(mImg(0), 16, 2.5, 13, 0)}
149
- <div style="position:absolute;inset:0;background:rgba(0,0,0,0.32);"></div>
150
- </div>
151
- <div id="cap-e1" class="clip" data-start="16.2" data-duration="2.1" data-track-index="14"
152
- style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
153
- font:900 128px/1.0 'Space Grotesk',sans-serif;color:#fff;
154
- text-shadow:0 4px 24px rgba(0,0,0,0.9);letter-spacing:-2px;">80% hands-on</div>
155
-
156
- <!-- ─── SCENE 4b: ENERGY CUT 2 β€” 18.5–21s ───────────────── -->
157
- <div id="s4b" class="clip sc" data-start="18.5" data-duration="2.5" data-track-index="15">
158
- ${mediaEl(mImg(1), 18.5, 2.5, 16, 0)}
159
- <div style="position:absolute;inset:0;background:rgba(0,0,0,0.32);"></div>
160
  </div>
161
- <div id="cap-e2" class="clip" data-start="18.7" data-duration="2.1" data-track-index="17"
162
- style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
163
- font:900 128px/1.0 'Space Grotesk',sans-serif;color:#fff;
164
- text-shadow:0 4px 24px rgba(0,0,0,0.9);letter-spacing:-2px;">Langsung<br>praktek</div>
165
-
166
- <!-- ─── SCENE 4c: ENERGY CUT 3 β€” 21–23s ─────────────────── -->
167
- <div id="s4c" class="clip sc" data-start="21" data-duration="2" data-track-index="18">
168
- ${mediaEl(mImg(2), 21, 2, 19, 0)}
169
- <div style="position:absolute;inset:0;background:rgba(0,0,0,0.32);"></div>
170
  </div>
171
- <div id="cap-e3" class="clip" data-start="21.2" data-duration="1.6" data-track-index="20"
172
- style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
173
- font:900 144px/1.0 'Space Grotesk',sans-serif;color:#9EF0C8;
174
- text-shadow:0 4px 24px rgba(0,0,0,0.9);letter-spacing:-2px;">Real<br>output \u2713</div>
175
-
176
- <!-- ─── SCENE 5: CTA 23–28s ──────────────────────────────── -->
177
- <div id="s5" class="clip sc" data-start="23" data-duration="5" data-track-index="21">
178
- ${mediaEl(mImg(0), 23, 5, 22, 0)}
179
- <div style="position:absolute;inset:0;background:rgba(5,3,18,0.72);"></div>
180
  </div>
181
- <div id="cap-cta1" class="clip" data-start="23.3" data-duration="4.4" data-track-index="23"
182
- style="position:absolute;top:34%;left:0;right:0;padding:0 56px;
183
- text-align:center;font:900 112px/1.15 'Space Grotesk',sans-serif;
184
- color:#fff;letter-spacing:-2px;text-shadow:0 4px 32px rgba(0,0,0,0.9);">
185
- Training AI<br>untuk tim kamu?
 
 
 
 
186
  </div>
187
- <div id="cap-cta2" class="clip" data-start="25.2" data-duration="2.5" data-track-index="24"
188
- style="position:absolute;top:59%;left:0;right:0;padding:0 56px;
189
- text-align:center;font:500 44px/1.2 'Plus Jakarta Sans',sans-serif;
190
- color:rgba(255,255,255,0.78);">DM \u00b7 atau klik link di bio</div>
191
-
192
- <!-- ─── SCENE 6: BRAND END CARD 28–30s ──────────────────── -->
193
- <div id="s6" class="clip sc" data-start="28" data-duration="2" data-track-index="25"
194
- style="background:linear-gradient(135deg,#0d0d1f 0%,#1a1045 50%,#0d0d1f 100%);">
195
- <div style="position:absolute;inset:0;background:radial-gradient(ellipse at 50% 40%,rgba(124,111,224,0.28) 0%,transparent 65%);"></div>
 
 
 
196
  </div>
197
- <div id="en" class="clip" data-start="28.2" data-duration="1.8" data-track-index="26"
198
- style="position:absolute;top:42%;left:0;right:0;text-align:center;
199
- font:800 80px/1.1 'Space Grotesk',sans-serif;color:#fff;">${trainerName}</div>
200
- <div id="et" class="clip" data-start="28.4" data-duration="1.6" data-track-index="27"
201
- style="position:absolute;top:52%;left:0;right:0;text-align:center;
202
- font:500 40px/1.2 'Plus Jakarta Sans',sans-serif;color:#9990ee;">${tagline}</div>
203
- <div id="ew" class="clip" data-start="28.6" data-duration="1.4" data-track-index="28"
204
- style="position:absolute;top:59%;left:0;right:0;text-align:center;
205
- font:400 28px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.48);">${website}</div>
206
-
207
- <!-- Music -->
 
208
  <audio data-start="0" data-duration="${dur}" data-track-index="50"
209
- data-volume="0.22" src="./assets/music.mp3"></audio>
210
 
211
- <!-- Grain overlay -->
212
  ${GRAIN}
213
 
214
- <!-- Persistent brand bar -->
215
  <div style="position:absolute;bottom:0;left:0;right:0;padding:20px 56px 34px;
216
- background:linear-gradient(transparent,rgba(0,0,0,0.65));z-index:97;">
217
  <span style="font:700 26px/1 'Space Grotesk',sans-serif;color:#fff;">${trainerName}</span>
218
- <span style="font:400 17px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.58);
219
- margin-left:8px;">\u00b7 ${tagline}</span>
220
  </div>
221
 
222
  <script>
223
  const tl = gsap.timeline({ paused: true });
224
 
225
- // S1 HOOK β€” slam from above (expo.out = fast, kinetic energy)
226
- tl.from("#cap-h1", { y: -100, autoAlpha: 0, duration: 0.22, ease: "expo.out" }, 0.2);
227
- tl.from("#cap-h2", { y: -100, autoAlpha: 0, duration: 0.22, ease: "expo.out" }, 0.35);
228
- tl.from("#cap-h3", { y: 40, autoAlpha: 0, duration: 0.35, ease: "power3.out" }, 0.52);
229
- // Zoom-through transition 1β†’2
230
- tl.to("#s1", { autoAlpha: 0, scale: 1.3, duration: 0.35, ease: "power4.inOut" }, 3.65);
231
-
232
- // S2 PROOF β€” scale pop stat (back.out = bouncy confidence)
233
- tl.fromTo("#s2", { autoAlpha: 0, scale: 0.85 }, { autoAlpha: 1, scale: 1, duration: 0.35, ease: "power4.inOut" }, 4);
234
- tl.from("#cap-stat", { scale: 0.55, autoAlpha: 0, duration: 0.5, ease: "back.out(2.5)" }, 4.3);
235
- tl.from("#lt", { x: -400, autoAlpha: 0, duration: 0.4, ease: "power3.out" }, 4.6);
236
- tl.to("#lt", { x: -400, autoAlpha: 0, duration: 0.3, ease: "power2.in" }, 7.3);
237
- // Push-slide transition 2β†’3
238
- tl.to("#s2", { autoAlpha: 0, xPercent: -15, duration: 0.32, ease: "power2.inOut" }, 7.68);
239
- tl.fromTo("#s3", { autoAlpha: 0, xPercent: 15 }, { autoAlpha: 1, xPercent: 0, duration: 0.32, ease: "power2.inOut" }, 7.68);
240
-
241
- // S3 TESTIMONIAL β€” opacity only (sine.inOut = calm, contrast to kinetic)
242
- tl.from("#cap-quote", { autoAlpha: 0, duration: 0.55, ease: "sine.inOut" }, 8.6);
243
- tl.to("#cap-quote", { autoAlpha: 0, duration: 0.3 }, 15.3);
244
- // Blur crossfade 3β†’4a
245
- tl.to("#s3", { autoAlpha: 0, filter: "blur(12px)", scale: 1.04, duration: 0.45, ease: "sine.inOut" }, 15.55);
246
-
247
- // S4a ENERGY β€” slide from left (power4.out = decisive)
248
- tl.from("#s4a", { autoAlpha: 0, duration: 0.18 }, 16);
249
- tl.from("#cap-e1", { x: -120, autoAlpha: 0, duration: 0.25, ease: "power4.out" }, 16.2);
250
- tl.to("#s4a", { autoAlpha: 0, duration: 0.18 }, 18.32);
251
-
252
- // S4b ENERGY β€” slide from right (variation)
253
- tl.from("#s4b", { autoAlpha: 0, duration: 0.18 }, 18.5);
254
- tl.from("#cap-e2", { x: 120, autoAlpha: 0, duration: 0.25, ease: "power4.out" }, 18.7);
255
- tl.to("#s4b", { autoAlpha: 0, duration: 0.18 }, 20.82);
256
-
257
- // S4c ENERGY β€” scale pop (variation, green energy color)
258
- tl.from("#s4c", { autoAlpha: 0, duration: 0.15 }, 21);
259
- tl.from("#cap-e3", { scale: 0.65, autoAlpha: 0, duration: 0.3, ease: "back.out(2.8)" }, 21.2);
260
- tl.to("#s4c", { autoAlpha: 0, scale: 1.1, duration: 0.22, ease: "power2.in" }, 22.78);
261
-
262
- // S5 CTA β€” slide up (power3.out)
263
- tl.fromTo("#s5", { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.4 }, 23);
264
- tl.from("#cap-cta1", { y: 80, autoAlpha: 0, duration: 0.5, ease: "power3.out" }, 23.3);
265
- tl.from("#cap-cta2", { y: 50, autoAlpha: 0, duration: 0.45, ease: "power3.out" }, 25.2);
266
- tl.to("#s5", { autoAlpha: 0, duration: 0.4 }, 27.6);
267
-
268
- // S6 BRAND END CARD β€” stagger up
269
- tl.from("#s6", { autoAlpha: 0, duration: 0.4 }, 28);
270
- tl.from("#en", { y: 36, autoAlpha: 0, duration: 0.45, ease: "power3.out" }, 28.2);
271
- tl.from("#et", { y: 28, autoAlpha: 0, duration: 0.4, ease: "power3.out" }, 28.4);
272
- tl.from("#ew", { y: 20, autoAlpha: 0, duration: 0.35, ease: "power3.out" }, 28.6);
273
-
274
- // CRITICAL: extend to full duration (prevents black frames)
275
- tl.to({}, { duration: ${dur} }, 0);
276
-
277
- // CRITICAL: key must match data-composition-id exactly
 
 
 
 
 
 
 
278
  window.__timelines = window.__timelines || {};
279
  window.__timelines["${compId}"] = tl;
280
  </script>
@@ -282,26 +266,17 @@ ${GRAIN}
282
  },
283
  };
284
 
285
- // Teaser: 30s fast-cut
286
  const teaser = {
287
- meta: { name: 'Event Teaser', icon: '\u26A1', description: '30s fast-cut teaser. 9:16.', format: '9:16', duration: 30, music: 'energetic' },
288
- buildHTML(media, config, compId, w, h, dur) {
289
- return testimonial.buildHTML(media, { ...config, clientName: config.clientName || 'COMING SOON' }, compId, w, h, dur);
290
- },
291
  };
292
-
293
  const trailer = {
294
  meta: { name: 'Cinematic Trailer', icon: '\uD83C\uDFAC', description: '60s cinematic recap. 9:16.', format: '9:16', duration: 60, music: 'cinematic' },
295
- buildHTML(media, config, compId, w, h, dur) {
296
- return testimonial.buildHTML(media, config, compId, w, h, dur);
297
- },
298
  };
299
-
300
  const community = {
301
- meta: { name: 'Community Story', icon: '\uD83E\uDD1D', description: '30s warm community. 9:16.', format: '9:16', duration: 30, music: 'warm' },
302
- buildHTML(media, config, compId, w, h, dur) {
303
- return testimonial.buildHTML(media, config, compId, w, h, dur);
304
- },
305
  };
306
 
307
  export const WORKFLOWS = { testimonial, teaser, trailer, community };
 
1
  // workflows/index.js β€” Dee Ferdinand Video Studio
2
+ // Fixed: black frames, cutoff, timing gaps, exit animation ban, autoAlpha
3
+ // Audio: no muted on video, research-backed data-volume levels
4
+ // Music: ElevenLabs lo-fi upbeat (primary) β†’ YuE (fallback) β†’ silence
5
+
6
+ const FONTS = `<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700;900&family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,600&display=swap" rel="stylesheet">`;
7
+ const GSAP = `<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ const GRAIN = `<div style="position:absolute;inset:0;pointer-events:none;z-index:98;overflow:hidden;">
 
 
 
 
 
 
 
 
 
 
10
  <div style="position:absolute;top:-50%;left:-50%;width:200%;height:200%;opacity:0.065;
11
  background:url('data:image/svg+xml,%3Csvg viewBox=\'0 0 256 256\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cfilter id=\'n\'%3E%3CfeTurbulence type=\'fractalNoise\' baseFrequency=\'0.65\' numOctaves=\'3\' stitchTiles=\'stitch\'/%3E%3C/filter%3E%3Crect width=\'100%25\' height=\'100%25\' filter=\'url(%23n)\'/%3E%3C/svg%3E');
12
  animation:grain 0.5s steps(1) infinite;"></div>
13
  </div>`;
14
 
15
+ function mediaEl(m, start, dur, tIdx, vol) {
16
+ if (!m) return '<div style="position:absolute;inset:0;background:#0d0d1f;"></div>';
17
+ if (m.mime?.startsWith('video/')) {
18
+ // NO muted β€” subject voice must come through. Framework controls via data-volume.
19
+ return `<div style="position:absolute;inset:0">
20
+ <video data-start="${start}" data-duration="${dur}" data-track-index="${tIdx}"
21
+ data-volume="${vol}" src="${m.rel}" playsinline
22
+ style="width:100%;height:100%;object-fit:cover;"></video></div>`;
23
+ }
24
+ return `<img src="${m.rel}" alt="" style="width:100%;height:100%;object-fit:cover;">` ;
25
+ }
26
+
27
+ // ── TESTIMONIAL 30s 9:16 ──────────────────────────────────────────────────────────
28
+ // Timing: each scene overlaps the previous by 0.35s (transition window)
29
+ // Track indices: each scene on unique track, captions on unique tracks
30
+ // NO exit animations (rule: transition IS the exit)
31
  const testimonial = {
32
+ meta: { name: 'Corporate Testimonial', icon: '\uD83C\uDFE2',
33
+ description: '30s kinetic testimonial. 9:16. ElevenLabs lo-fi music.',
34
+ format: '9:16', duration: 30, music: 'lofi-upbeat' },
 
 
35
 
36
  buildHTML(media, config, compId, w, h, dur) {
37
+ const { clientName='AI Training', trainerName='Dee Ferdinand',
38
+ tagline='AI Corporate Trainer', website='deeferdinand.com' } = config;
39
  const vids = media.filter(m => m.mime?.startsWith('video/'));
40
  const imgs = media.filter(m => m.mime?.startsWith('image/'));
41
+ const pick = (i) => media[i % Math.max(media.length,1)] || media[0];
42
+ const img = (i) => imgs[i % Math.max(imgs.length,1)] || pick(i);
43
+ const mVid = vids[0] || null;
44
+ const yr = new Date().getFullYear();
45
+
46
+ // Strict scene timing: NO gaps, transitions overlap by 0.35s on DIFFERENT tracks
47
+ // S1: 0-4s S2: 3.65-8s (T1 at 3.65) S3: 7.65-16.5s (T2 at 7.65)
48
+ // S4a: 15.85-18.5s S4b: 18.2-21s S4c: 20.7-23.2s
49
+ // S5: 22.85-28.5s (T3 at 22.85) S6: 28.2-30.5s
50
+ // All captions on own tracks, gap from scene start: 0.2s
51
 
52
  return `<!DOCTYPE html>
53
+ <html><head><meta charset="utf-8">${FONTS}${GSAP}
54
+ <style>
55
+ * {margin:0;padding:0;box-sizing:border-box}
56
+ body {background:#0d0d1f;overflow:hidden}
57
+ #root {position:relative;width:${w}px;height:${h}px;overflow:hidden;background:#0d0d1f}
58
+ .sc {position:absolute;inset:0}
59
+ .glow-c {position:absolute;border-radius:50%;pointer-events:none}
60
+ @keyframes grain{0%,100%{transform:translate(0,0)}10%{transform:translate(-5%,-5%)}30%{transform:translate(5%,-10%)}50%{transform:translate(-10%,5%)}70%{transform:translate(0,10%)}90%{transform:translate(10%,5%)}}
 
61
  </style></head><body>
62
+ <div id="root" data-composition-id="${compId}" data-start="0" data-duration="${dur}" data-width="${w}" data-height="${h}">
63
 
64
+ <!-- Persistent depth glows -->
65
+ <div class="glow-c" style="width:600px;height:600px;top:8%;left:50%;transform:translateX(-50%);
66
+ background:radial-gradient(ellipse,rgba(124,111,224,0.16) 0%,transparent 70%);"></div>
67
+ <div class="glow-c" style="width:400px;height:400px;bottom:20%;right:-80px;
68
+ background:radial-gradient(ellipse,rgba(224,111,154,0.12) 0%,transparent 70%);"></div>
69
 
70
+ <!-- S1 HOOK 0-4s track:0 -->
71
  <div id="s1" class="clip sc" data-start="0" data-duration="4" data-track-index="0">
72
+ ${mediaEl(img(0),0,4,1,0)}
73
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.40);"></div>
74
  </div>
75
+ <div id="h1" class="clip" data-start="0.2" data-duration="3.5" data-track-index="2"
76
+ style="position:absolute;bottom:580px;left:0;right:0;padding:0 56px;text-align:center;
77
+ font:900 160px/1.0 'Space Grotesk',sans-serif;color:#fff;
78
+ text-shadow:0 6px 40px rgba(0,0,0,0.95);letter-spacing:-3px;">Ini yang</div>
79
+ <div id="h2" class="clip" data-start="0.35" data-duration="3.3" data-track-index="3"
80
+ style="position:absolute;bottom:376px;left:0;right:0;padding:0 56px;text-align:center;
81
+ font:900 160px/1.0 'Space Grotesk',sans-serif;color:#7C6FE0;
82
+ text-shadow:0 6px 40px rgba(0,0,0,0.9);letter-spacing:-3px;">terjadi</div>
83
+ <div id="h3" class="clip" data-start="0.52" data-duration="3.1" data-track-index="4"
84
+ style="position:absolute;bottom:260px;left:0;right:0;padding:0 56px;text-align:center;
85
+ font:700 72px/1.2 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.82);">ketika tim belajar AI</div>
86
+
87
+ <!-- S2 PROOF 3.65-7.65s track:5 β€” overlaps S1 by 0.35s for zoom-through transition -->
88
+ <div id="s2" class="clip sc" data-start="3.65" data-duration="4" data-track-index="5">
89
+ ${mediaEl(img(1),3.65,4,6,0)}
90
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.52);"></div>
 
 
91
  </div>
92
+ <div id="st" class="clip" data-start="3.95" data-duration="3.4" data-track-index="7"
93
+ style="position:absolute;top:50%;left:0;right:0;transform:translateY(-55%);text-align:center;color:#fff;">
 
 
94
  <div style="font:900 160px/1.0 'Space Grotesk',sans-serif;letter-spacing:-4px;
95
+ text-shadow:0 6px 40px rgba(0,0,0,0.9);">1,000+</div>
96
+ <div style="font:600 48px/1.2 'Plus Jakarta Sans',sans-serif;opacity:0.85;margin-top:16px;">profesional terlatih</div>
97
+ <div style="font:400 30px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.55);margin-top:10px;">sejak 2023</div>
 
 
98
  </div>
99
+ <div id="lt" class="clip" data-start="4.2" data-duration="3.1" data-track-index="8"
100
+ style="position:absolute;bottom:180px;left:0;padding:0 0 0 56px;">
 
101
  <div style="background:rgba(10,10,25,0.85);backdrop-filter:blur(20px) saturate(150%);
102
+ border-left:6px solid #7C6FE0;border-radius:0 16px 16px 0;padding:18px 32px;display:inline-block;">
 
103
  <div style="font:700 40px/1.2 'Plus Jakarta Sans',sans-serif;color:#fff;">${clientName}</div>
104
+ <div style="font:400 24px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.62);margin-top:6px;">AI Training \u00b7 ${yr}</div>
 
105
  </div>
106
  </div>
107
 
108
+ <!-- S3 TESTIMONIAL 7.65-16.5s track:9 -->
109
+ <div id="s3" class="clip sc" data-start="7.65" data-duration="8.85" data-track-index="9">
110
  ${mVid
111
+ ? `<div style="position:absolute;inset:0">
112
+ <video data-start="7.65" data-duration="8.85" data-track-index="10"
113
+ data-volume="0.88" src="${mVid.rel}" playsinline
114
+ style="width:100%;height:100%;object-fit:cover;"></video></div>`
115
+ : `${mediaEl(img(2),7.65,8.85,10,0)}`}
 
 
 
 
116
  <div style="position:absolute;bottom:0;left:0;right:0;height:500px;
117
+ background:linear-gradient(transparent,rgba(0,0,0,0.82));"></div>
118
  </div>
119
+ <div id="q" class="clip" data-start="8.2" data-duration="7.7" data-track-index="11"
120
+ style="position:absolute;bottom:240px;left:0;right:0;padding:0 56px;text-align:center;
121
+ font:600 72px/1.3 'Plus Jakarta Sans',sans-serif;color:#fff;font-style:italic;
122
+ text-shadow:0 4px 24px rgba(0,0,0,0.95);">
 
 
123
  &ldquo;Saya pikir AI susah.<br>Ternyata langsung bisa!&rdquo;
124
  </div>
125
 
126
+ <!-- ENERGY 4 cuts: each ~2s, hard cuts, different tracks -->
127
+ <!-- S4a: 15.85-18.5s track:12 -->
128
+ <div id="s4a" class="clip sc" data-start="15.85" data-duration="2.65" data-track-index="12">
129
+ ${mediaEl(img(0),15.85,2.65,13,0)}
130
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.30);"></div>
 
 
 
 
 
 
 
 
 
131
  </div>
132
+ <div id="e1" class="clip" data-start="16.05" data-duration="2.3" data-track-index="14"
133
+ style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
134
+ font:900 128px/1.0 'Space Grotesk',sans-serif;color:#fff;
135
+ text-shadow:0 4px 24px rgba(0,0,0,0.9);letter-spacing:-2px;">80%<br>hands-on</div>
136
+
137
+ <!-- S4b: 18.2-21s track:15 -->
138
+ <div id="s4b" class="clip sc" data-start="18.2" data-duration="2.8" data-track-index="15">
139
+ ${mediaEl(img(1),18.2,2.8,16,0)}
140
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.30);"></div>
141
  </div>
142
+ <div id="e2" class="clip" data-start="18.4" data-duration="2.4" data-track-index="17"
143
+ style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
144
+ font:900 128px/1.0 'Space Grotesk',sans-serif;color:#fff;
145
+ text-shadow:0 4px 24px rgba(0,0,0,0.9);letter-spacing:-2px;">Langsung<br>praktek</div>
146
+
147
+ <!-- S4c: 20.7-23.2s track:18 -->
148
+ <div id="s4c" class="clip sc" data-start="20.7" data-duration="2.5" data-track-index="18">
149
+ ${mediaEl(img(2),20.7,2.5,19,0)}
150
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.30);"></div>
151
  </div>
152
+ <div id="e3" class="clip" data-start="20.9" data-duration="2.1" data-track-index="20"
153
+ style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
154
+ font:900 144px/1.0 'Space Grotesk',sans-serif;color:#9EF0C8;
155
+ text-shadow:0 4px 24px rgba(0,0,0,0.9);letter-spacing:-2px;">Real<br>output \u2713</div>
156
+
157
+ <!-- S5 CTA 22.85-28.5s track:21 -->
158
+ <div id="s5" class="clip sc" data-start="22.85" data-duration="5.65" data-track-index="21">
159
+ ${mediaEl(img(0),22.85,5.65,22,0)}
160
+ <div style="position:absolute;inset:0;background:rgba(5,3,18,0.70);"></div>
161
  </div>
162
+ <div id="c1" class="clip" data-start="23.1" data-duration="5.1" data-track-index="23"
163
+ style="position:absolute;top:34%;left:0;right:0;padding:0 56px;text-align:center;
164
+ font:900 112px/1.15 'Space Grotesk',sans-serif;color:#fff;
165
+ letter-spacing:-2px;text-shadow:0 4px 32px rgba(0,0,0,0.9);">Training AI<br>untuk tim kamu?</div>
166
+ <div id="c2" class="clip" data-start="25" data-duration="3.15" data-track-index="24"
167
+ style="position:absolute;top:59%;left:0;right:0;padding:0 56px;text-align:center;
168
+ font:500 44px/1.2 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.78);">DM \u00b7 atau klik link di bio</div>
169
+
170
+ <!-- S6 BRAND END 28.2-30.5s track:25 -->
171
+ <div id="s6" class="clip sc" data-start="28.2" data-duration="2.3" data-track-index="25"
172
+ style="background:linear-gradient(135deg,#0d0d1f 0%,#1a1045 50%,#0d0d1f 100%);">
173
+ <div style="position:absolute;inset:0;background:radial-gradient(ellipse at 50% 40%,rgba(124,111,224,0.26) 0%,transparent 65%);"></div>
174
  </div>
175
+ <div id="en" class="clip" data-start="28.4" data-duration="2.1" data-track-index="26"
176
+ style="position:absolute;top:42%;left:0;right:0;text-align:center;
177
+ font:800 80px/1.1 'Space Grotesk',sans-serif;color:#fff;">${trainerName}</div>
178
+ <div id="et" class="clip" data-start="28.6" data-duration="1.9" data-track-index="27"
179
+ style="position:absolute;top:52%;left:0;right:0;text-align:center;
180
+ font:500 40px/1.2 'Plus Jakarta Sans',sans-serif;color:#9990ee;">${tagline}</div>
181
+ <div id="ew" class="clip" data-start="28.8" data-duration="1.7" data-track-index="28"
182
+ style="position:absolute;top:59%;left:0;right:0;text-align:center;
183
+ font:400 28px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.48);">${website}</div>
184
+
185
+ <!-- Music: lo-fi upbeat, generated by ElevenLabs -->
186
+ <!-- Volume 0.10 during energy/CTA, see audio-design.md -->
187
  <audio data-start="0" data-duration="${dur}" data-track-index="50"
188
+ data-volume="0.10" src="./assets/music.mp3"></audio>
189
 
 
190
  ${GRAIN}
191
 
192
+ <!-- Persistent brand bar (not a clip β€” always visible) -->
193
  <div style="position:absolute;bottom:0;left:0;right:0;padding:20px 56px 34px;
194
+ background:linear-gradient(transparent,rgba(0,0,0,0.65));z-index:97;">
195
  <span style="font:700 26px/1 'Space Grotesk',sans-serif;color:#fff;">${trainerName}</span>
196
+ <span style="font:400 17px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.58);margin-left:8px;">\u00b7 ${tagline}</span>
 
197
  </div>
198
 
199
  <script>
200
  const tl = gsap.timeline({ paused: true });
201
 
202
+ // S1 HOOK β€” slam from above (expo.out: kinetic, fast)
203
+ tl.from("#h1", { y: -100, autoAlpha: 0, duration: 0.22, ease: "expo.out" }, 0.2);
204
+ tl.from("#h2", { y: -100, autoAlpha: 0, duration: 0.22, ease: "expo.out" }, 0.35);
205
+ tl.from("#h3", { y: 40, autoAlpha: 0, duration: 0.35, ease: "power3.out" }, 0.52);
206
+
207
+ // TRANSITION 1β†’2: Zoom-through (dramatic opener)
208
+ // Rule: NO exit animation on s1 content β€” transition handles the handoff
209
+ const T1 = 3.65;
210
+ tl.to("#s1", { autoAlpha: 0, scale: 1.32, duration: 0.35, ease: "power4.inOut" }, T1);
211
+ tl.fromTo("#s2", { autoAlpha: 0, scale: 0.80 }, { autoAlpha: 1, scale: 1, duration: 0.35, ease: "power4.inOut" }, T1);
212
+
213
+ // S2 PROOF β€” scale pop (back.out: bouncy confidence)
214
+ tl.from("#st", { scale: 0.55, autoAlpha: 0, duration: 0.5, ease: "back.out(2.5)" }, 3.95);
215
+ tl.from("#lt", { x: -400, autoAlpha: 0, duration: 0.4, ease: "power3.out" }, 4.2);
216
+ // NO exit tween on #lt β€” transition covers it
217
+
218
+ // TRANSITION 2β†’3: Push-slide left (editorial)
219
+ const T2 = 7.65;
220
+ tl.to("#s2", { autoAlpha: 0, xPercent: -18, duration: 0.32, ease: "power2.inOut" }, T2);
221
+ tl.fromTo("#s3", { autoAlpha: 0, xPercent: 18 }, { autoAlpha: 1, xPercent: 0, duration: 0.32, ease: "power2.inOut" }, T2);
222
+
223
+ // S3 TESTIMONIAL β€” opacity only (sine.inOut: calm, intentional contrast)
224
+ tl.from("#q", { autoAlpha: 0, duration: 0.55, ease: "sine.inOut" }, 8.2);
225
+ // NO exit tween on #q β€” transition handles
226
+
227
+ // TRANSITION 3β†’4a: Blur crossfade (drift into energy)
228
+ const T3 = 15.85;
229
+ tl.to("#s3", { autoAlpha: 0, filter: "blur(14px)", scale: 1.04, duration: 0.45, ease: "sine.inOut" }, T3);
230
+ tl.from("#s4a", { autoAlpha: 0, duration: 0.22 }, T3 + 0.23);
231
+
232
+ // S4a ENERGY β€” slide from left (power4.out: decisive)
233
+ tl.from("#e1", { x: -120, autoAlpha: 0, duration: 0.25, ease: "power4.out" }, 16.05);
234
+
235
+ // S4b: Hard cut (disruption intentional) + slide from right
236
+ tl.from("#s4b", { autoAlpha: 0, duration: 0.18 }, 18.2);
237
+ tl.from("#e2", { x: 120, autoAlpha: 0, duration: 0.25, ease: "power4.out" }, 18.4);
238
+
239
+ // S4c: Hard cut + scale pop (energy green color)
240
+ tl.from("#s4c", { autoAlpha: 0, duration: 0.15 }, 20.7);
241
+ tl.from("#e3", { scale: 0.60, autoAlpha: 0, duration: 0.3, ease: "back.out(2.8)" }, 20.9);
242
+
243
+ // TRANSITION 4β†’5: Hard cut into CTA
244
+ tl.from("#s5", { autoAlpha: 0, duration: 0.25 }, 22.85);
245
+ tl.from("#c1", { y: 80, autoAlpha: 0, duration: 0.5, ease: "power3.out" }, 23.1);
246
+ tl.from("#c2", { y: 50, autoAlpha: 0, duration: 0.45, ease: "power3.out" }, 25);
247
+
248
+ // TRANSITION 5β†’6: Brand end card
249
+ const T5 = 28.2;
250
+ tl.to("#s5", { autoAlpha: 0, duration: 0.3 }, T5);
251
+ tl.from("#s6", { autoAlpha: 0, duration: 0.3 }, T5);
252
+ // Final scene IS allowed exit animations β€” but we fade in instead
253
+ tl.from("#en", { y: 36, autoAlpha: 0, duration: 0.45, ease: "power3.out" }, 28.4);
254
+ tl.from("#et", { y: 28, autoAlpha: 0, duration: 0.40, ease: "power3.out" }, 28.6);
255
+ tl.from("#ew", { y: 20, autoAlpha: 0, duration: 0.35, ease: "power3.out" }, 28.8);
256
+
257
+ // CRITICAL: extend timeline to prevent cutoff + black frames
258
+ // Use tl.set() not tl.to() β€” tl.to({},{duration:X}) can conflict
259
+ tl.set({}, {}, ${dur});
260
+
261
+ // CRITICAL: key MUST match data-composition-id exactly
262
  window.__timelines = window.__timelines || {};
263
  window.__timelines["${compId}"] = tl;
264
  </script>
 
266
  },
267
  };
268
 
 
269
  const teaser = {
270
+ meta: { name: 'Event Teaser', icon: '\u26A1', description: '30s fast-cut teaser. 9:16. ElevenLabs energetic.', format: '9:16', duration: 30, music: 'energetic' },
271
+ buildHTML(media, config, compId, w, h, dur) { return testimonial.buildHTML(media, config, compId, w, h, dur); },
 
 
272
  };
 
273
  const trailer = {
274
  meta: { name: 'Cinematic Trailer', icon: '\uD83C\uDFAC', description: '60s cinematic recap. 9:16.', format: '9:16', duration: 60, music: 'cinematic' },
275
+ buildHTML(media, config, compId, w, h, dur) { return testimonial.buildHTML(media, config, compId, w, h, dur); },
 
 
276
  };
 
277
  const community = {
278
+ meta: { name: 'Community Story', icon: '\uD83E\uDD1D', description: '30s warm community story. 9:16.', format: '9:16', duration: 30, music: 'warm' },
279
+ buildHTML(media, config, compId, w, h, dur) { return testimonial.buildHTML(media, config, compId, w, h, dur); },
 
 
280
  };
281
 
282
  export const WORKFLOWS = { testimonial, teaser, trailer, community };