Dee Ferdinand commited on
Commit
a2e4ffd
·
1 Parent(s): d3cc583

feat: complete rewrite — correct HyperFrames compositions with GSAP + class=clip + window.__timelines

Browse files

- Add SKILL.md: HyperFrames production skill with all rules, patterns, vocabulary
- Rewrite composer.js: generates proper HyperFrames HTML (GSAP timelines, clip classes, registered timelines)
- Testimonial: 30s workflow with hook/proof/story/energy/cta/brand structure
- All 5 hard rules enforced: class=clip, paused timeline, no media control, no Math.random, tl.set extend
- GSAP CDN loaded from jsdelivr, all animations use correct easing vocabulary
- Video wrapped in divs (never animate video element directly)
- Lower thirds, stat counter, brand end card patterns

lib/composer.js CHANGED
@@ -1,107 +1,44 @@
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
  }
 
1
  import fs from 'fs';
2
  import path from 'path';
3
 
4
+ function pick(media, preferVideo, idx) {
5
+ const v = media.filter(m => m.mime?.startsWith('video/'));
6
+ const i = media.filter(m => m.mime?.startsWith('image/'));
7
+ const pool = preferVideo ? (v.length ? v : i) : (i.length ? i : v);
8
+ return pool[idx % Math.max(pool.length, 1)] || media[idx % Math.max(media.length, 1)] || null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
10
 
11
  export async function buildComposition(compDir, projectDir, musicPath, config, workflow, onProgress) {
12
  const files = config._files || [];
13
+ const { trainerName = 'Dee Ferdinand', tagline = 'AI Corporate Trainer',
14
+ website = 'deeferdinand.com', clientName = '', format = '9:16', duration: totalDur = 30 } = config;
15
 
16
  const assetsDir = path.join(compDir, 'assets');
17
  fs.mkdirSync(assetsDir, { recursive: true });
18
 
19
+ // Copy uploaded files into assets/
20
  const linkedMedia = [];
21
  for (const f of files) {
22
  const dest = path.join(assetsDir, path.basename(f.path));
23
  if (!fs.existsSync(dest)) fs.copyFileSync(f.path, dest);
24
+ linkedMedia.push({ ...f, rel: `./assets/${path.basename(f.path)}` });
25
  }
26
 
27
+ // Copy music
28
+ const musicDest = path.join(assetsDir, 'music.mp3');
29
  if (!fs.existsSync(musicDest)) fs.copyFileSync(musicPath, musicDest);
 
30
 
31
  onProgress?.(0.2);
 
 
32
 
33
  const [w, h] = format === '9:16' ? [1080, 1920] : format === '1:1' ? [1080, 1080] : [1920, 1080];
34
+ const compId = `dee-${config.workflow}-${Date.now()}`;
35
 
36
+ // Write meta.json (HyperFrames reads this)
37
+ fs.writeFileSync(path.join(compDir, 'meta.json'), JSON.stringify(
38
+ { name: compId, id: compId, duration: totalDur, width: w, height: h, fps: 30 }, null, 2
39
+ ));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ const html = workflow.buildHTML(linkedMedia, config, compId, w, h, totalDur);
42
  fs.writeFileSync(path.join(compDir, 'index.html'), html);
43
  onProgress?.(1.0);
44
  }
skills/hyperframes-video/SKILL.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HyperFrames Video Production Skill
2
+ # For: Dee Ferdinand AI Corporate Training videos
3
+
4
+ ## THE 5 HARD RULES (breaking any = broken render)
5
+
6
+ 1. Every timed element MUST have `class="clip"` + `data-start` + `data-duration` + `data-track-index`
7
+ 2. GSAP timeline MUST be `{ paused: true }` AND registered: `window.__timelines["COMPOSITION_ID"] = tl`
8
+ - Key MUST exactly match `data-composition-id` on root div
9
+ 3. NEVER call `.play()` `.pause()` or set `.currentTime` on video/audio in scripts
10
+ 4. NEVER use `Math.random()` — use seeded PRNG for reproducibility
11
+ 5. Always extend: `tl.set({}, {}, TOTAL_DURATION)` — or render cuts off at last tween
12
+
13
+ ## CORRECT MINIMAL COMPOSITION
14
+
15
+ ```html
16
+ <div id="root" data-composition-id="my-video" data-start="0" data-width="1080" data-height="1920">
17
+ <!-- Timed element: MUST have class="clip" -->
18
+ <div id="scene-1" class="clip" data-start="0" data-duration="5" data-track-index="0"
19
+ style="position:absolute;inset:0;background:#000;">
20
+ <!-- Wrap video, never animate video element directly -->
21
+ <div id="vid-wrap" style="position:absolute;inset:0;">
22
+ <video data-start="0" data-duration="5" data-track-index="1" data-volume="0.8"
23
+ src="./assets/clip.mp4" muted playsinline
24
+ style="width:100%;height:100%;object-fit:cover;"></video>
25
+ </div>
26
+ </div>
27
+ <div id="cap" class="clip" data-start="0.3" data-duration="4.5" data-track-index="2"
28
+ style="position:absolute;bottom:300px;left:0;right:0;text-align:center;
29
+ font-size:72px;font-weight:900;color:#fff;">
30
+ Caption text here
31
+ </div>
32
+ <audio data-start="0" data-duration="30" data-track-index="20" data-volume="0.25"
33
+ src="./assets/music.mp3"></audio>
34
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
35
+ <script>
36
+ const tl = gsap.timeline({ paused: true });
37
+ tl.from("#cap", { y: 40, opacity: 0, duration: 0.4, ease: "power3.out" }, 0.3);
38
+ tl.set({}, {}, 30); // CRITICAL: extend to full duration
39
+ window.__timelines = window.__timelines || {};
40
+ window.__timelines["my-video"] = tl; // CRITICAL: key = data-composition-id
41
+ </script>
42
+ </div>
43
+ ```
44
+
45
+ ## MOTION VOCABULARY
46
+ snappily = power4.out 0.2s | smoothly = power2.out 0.4s | dramatically = expo.out 0.6s
47
+ bouncy = back.out(1.7) | stat pop = back.out(2) scale from 0.75 | dreamy = sine.inOut
48
+
49
+ ## 30s TESTIMONIAL STRUCTURE (9:16)
50
+ 0.0–4.0s HOOK photo + big stacked caption, slow Ken Burns zoom
51
+ 4.0–8.0s PROOF stat counter scale-pop + lower third client badge
52
+ 8.0–16.0s STORY testimonial video (real footage, caption below)
53
+ 16.0–23.0s ENERGY 3 fast photo cuts (2-2.5s each), kinetic captions
54
+ 23.0–28.0s CTA dark overlay photo, CTA caption stack
55
+ 28.0–30.0s BRAND dark gradient end card, name + tagline + website
56
+
57
+ ## COMMON BUGS
58
+ - Missing class="clip" → element always visible, overlaps everything
59
+ - window.__timelines key mismatch → static, no animation
60
+ - Animate video directly → frames stop rendering
61
+ - No tl.set at end → render stops early
62
+ - audio.play() in script → framework sync breaks
workflows/index.js CHANGED
@@ -1,92 +1,416 @@
1
- function pick(media, preferVideo, idx) {
2
- const v = media.filter(m => m.mime?.startsWith('video/'));
3
- const i = media.filter(m => m.mime?.startsWith('image/'));
4
- const pool = preferVideo ? (v.length ? v : i) : (i.length ? i : v);
5
- return pool[idx % Math.max(pool.length, 1)] || media[idx % Math.max(media.length, 1)] || null;
 
 
 
 
 
 
 
 
 
 
6
  }
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  const testimonial = {
9
- meta: { name: 'Corporate Testimonial', description: '75s social proof video for corporate AI training.', format: '9:16', duration: 75, music: 'corporate', icon: '🏢' },
10
- generateCuts(media, cfg) {
11
- const { clientName = 'AI Training', trainerName = 'Dee Ferdinand' } = cfg;
12
- return [
13
- { start: 0, duration: 3, mediaFile: pick(media, true, 0), caption: clientName ? `${clientName} × AI Training` : 'AI Corporate Training', captionStyle: 'hook' },
14
- { start: 3, duration: 2, mediaFile: pick(media, false, 0), caption: `${trainerName} · AI Corporate Trainer`, captionStyle: 'badge' },
15
- { start: 5, duration: 5, mediaFile: pick(media, false, 1), caption: '1,000+ professionals trained since 2023', captionStyle: 'stat' },
16
- { start: 10, duration: 5, mediaFile: pick(media, true, 1), caption: '80% hands-on · langsung praktek', captionStyle: 'default' },
17
- { start: 15, duration: 8, mediaFile: pick(media, true, 2), caption: '"Saya pikir AI itu susah. Ternyata..."', captionStyle: 'default' },
18
- { start: 23, duration: 4, mediaFile: pick(media, false, 2), caption: clientName ? `${clientName} · ${new Date().getFullYear()}` : null, captionStyle: 'badge' },
19
- { start: 27, duration: 5, mediaFile: pick(media, true, 3), caption: 'Hands-on prompting. Real output.', captionStyle: 'default' },
20
- { start: 32, duration: 4, mediaFile: pick(media, false, 3), caption: null },
21
- { start: 36, duration: 7, mediaFile: pick(media, true, 4), caption: '"Sekarang saya bisa buat konten sendiri."', captionStyle: 'default' },
22
- { start: 43, duration: 4, mediaFile: pick(media, false, 4), caption: null },
23
- { start: 47, duration: 8, mediaFile: pick(media, true, 5), caption: 'Energi yang berbeda. Hasil yang nyata.', captionStyle: 'default' },
24
- { start: 55, duration: 8, mediaFile: pick(media, true, 0), caption: 'Training AI untuk tim kamu?', captionStyle: 'hook' },
25
- { start: 63, duration: 7, mediaFile: pick(media, false, 5), caption: 'DM · atau klik link di bio', captionStyle: 'default' },
26
- ];
27
  },
28
- };
29
 
30
- const teaser = {
31
- meta: { name: 'Event Teaser', description: '30s fast-cut teaser for upcoming events.', format: '9:16', duration: 30, music: 'energetic', icon: '⚡' },
32
- generateCuts(media, cfg) {
33
- const { clientName = 'Next Event' } = cfg;
34
- const m = media;
35
- return [
36
- { start: 0, duration: 2, mediaFile: pick(m,true,0), caption: 'COMING SOON', captionStyle: 'hook' },
37
- { start: 2, duration: 2, mediaFile: pick(m,false,1), caption: null },
38
- { start: 4, duration: 2, mediaFile: pick(m,true,1), caption: clientName, captionStyle: 'badge' },
39
- { start: 6, duration: 2, mediaFile: pick(m,false,2), caption: null },
40
- { start: 8, duration: 2, mediaFile: pick(m,true,2), caption: 'AI Training', captionStyle: 'default' },
41
- { start: 10, duration: 2, mediaFile: pick(m,false,3), caption: null },
42
- { start: 12, duration: 2, mediaFile: pick(m,true,3), caption: 'Hands-on. Real tools.', captionStyle: 'default' },
43
- { start: 14, duration: 3, mediaFile: pick(m,true,0), caption: 'Real results. Real fast.', captionStyle: 'hook' },
44
- { start: 17, duration: 3, mediaFile: pick(m,false,4), caption: null },
45
- { start: 20, duration: 3, mediaFile: pick(m,true,0), caption: 'Save your spot. Link in bio.', captionStyle: 'default' },
46
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  },
48
  };
49
 
50
- const trailer = {
51
- meta: { name: 'Cinematic Trailer', description: '60s cinematic event recap or brand trailer.', format: '9:16', duration: 60, music: 'cinematic', icon: '🎬' },
52
- generateCuts(media, cfg) {
53
- const { clientName = '', trainerName = 'Dee Ferdinand' } = cfg;
54
- const m = media;
55
- return [
56
- { start: 0, duration: 5, mediaFile: pick(m,true,0), caption: 'Ini bukan teori.', captionStyle: 'hook' },
57
- { start: 5, duration: 4, mediaFile: pick(m,false,0), caption: 'Ini yang benar-benar terjadi.', captionStyle: 'default' },
58
- { start: 9, duration: 4, mediaFile: pick(m,true,1), caption: clientName || null, captionStyle: 'badge' },
59
- { start: 13, duration: 4, mediaFile: pick(m,false,1), caption: null },
60
- { start: 17, duration: 5, mediaFile: pick(m,true,2), caption: 'Prompt. Praktek. Hasil.', captionStyle: 'default' },
61
- { start: 22, duration: 4, mediaFile: pick(m,false,2), caption: null },
62
- { start: 26, duration: 5, mediaFile: pick(m,true,3), caption: '"Saya tidak sangka bisa."', captionStyle: 'default' },
63
- { start: 31, duration: 4, mediaFile: pick(m,false,3), caption: null },
64
- { start: 35, duration: 5, mediaFile: pick(m,true,0), caption: '1,000+ professionals trained', captionStyle: 'stat' },
65
- { start: 40, duration: 5, mediaFile: pick(m,true,4), caption: null },
66
- { start: 45, duration: 5, mediaFile: pick(m,false,0), caption: `${trainerName} · AI Corporate Trainer`, captionStyle: 'brand' },
67
- { start: 50, duration: 5, mediaFile: pick(m,true,1), caption: 'Training AI untuk tim kamu?', captionStyle: 'hook' },
68
- ];
69
  },
70
- };
 
 
 
71
 
72
- const community = {
73
- meta: { name: 'Community Story', description: '60s warm documentary for community/church sessions.', format: '9:16', duration: 60, music: 'warm', icon: '🤝' },
74
- generateCuts(media, cfg) {
75
- const { clientName = 'Community Training', trainerName = 'Dee Ferdinand' } = cfg;
76
- const m = media;
77
- return [
78
- { start: 0, duration: 4, mediaFile: pick(m,false,0), caption: 'AI bukan cuma untuk korporat.', captionStyle: 'hook' },
79
- { start: 4, duration: 5, mediaFile: pick(m,true,0), caption: clientName, captionStyle: 'badge' },
80
- { start: 9, duration: 5, mediaFile: pick(m,false,1), caption: 'Guru. Ibu rumah tangga. Mahasiswa.', captionStyle: 'default' },
81
- { start: 14, duration: 5, mediaFile: pick(m,true,1), caption: null },
82
- { start: 19, duration: 5, mediaFile: pick(m,false,2), caption: 'Semua orang bisa belajar AI.', captionStyle: 'default' },
83
- { start: 24, duration: 8, mediaFile: pick(m,true,2), caption: '"Saya tidak sangka bisa."', captionStyle: 'default' },
84
- { start: 32, duration: 5, mediaFile: pick(m,false,3), caption: null },
85
- { start: 37, duration: 5, mediaFile: pick(m,true,3), caption: 'Belajar bersama. Tumbuh bersama.', captionStyle: 'default' },
86
- { start: 42, duration: 5, mediaFile: pick(m,false,0), caption: `${trainerName} · ${new Date().getFullYear()}`, captionStyle: 'brand' },
87
- { start: 47, duration: 5, mediaFile: pick(m,true,0), caption: 'Training AI untuk komunitas kamu?', captionStyle: 'hook' },
88
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  },
90
  };
91
 
 
 
 
 
92
  export const WORKFLOWS = { testimonial, teaser, trailer, community };
 
1
+ // workflows/index.js
2
+ // All workflows produce correct HyperFrames HTML:
3
+ // - Every timed element has class="clip" + data-start + data-duration + data-track-index
4
+ // - GSAP timeline is paused: true and registered on window.__timelines[compId]
5
+ // - Video elements wrapped in divs, never animated directly
6
+ // - tl.set({},{},DURATION) extends timeline to full length
7
+
8
+ function assetRef(media, idx) {
9
+ if (!media || !media.length) return './assets/placeholder.jpg';
10
+ return media[idx % media.length]?.rel || './assets/placeholder.jpg';
11
+ }
12
+
13
+ function videoOrImg(m) {
14
+ if (!m) return null;
15
+ return m.mime?.startsWith('video/') ? 'video' : 'img';
16
  }
17
 
18
+ function mediaEl(m, start, duration, trackIdx, volume = 0) {
19
+ if (!m) return '';
20
+ if (m.mime?.startsWith('video/')) {
21
+ return `<div style="position:absolute;inset:0">
22
+ <video data-start="${start}" data-duration="${duration}" data-track-index="${trackIdx}"
23
+ data-volume="${volume}" src="${m.rel}" muted playsinline
24
+ style="width:100%;height:100%;object-fit:cover;"></video>
25
+ </div>`;
26
+ }
27
+ return `<img src="${m.rel}" alt="" style="width:100%;height:100%;object-fit:cover;">`;
28
+ }
29
+
30
+ // ── TESTIMONIAL (30s · 9:16) ─────────────────────────────────────────────────
31
  const testimonial = {
32
+ meta: {
33
+ name: 'Corporate Testimonial', icon: '🏢',
34
+ description: '30s social proof video. 9:16 vertical.',
35
+ format: '9:16', duration: 30, music: 'corporate',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  },
 
37
 
38
+ buildHTML(media, config, compId, w, h, dur) {
39
+ const { clientName = 'Training Session', trainerName = 'Dee Ferdinand',
40
+ tagline = 'AI Corporate Trainer', website = 'deeferdinand.com' } = config;
41
+ const videos = media.filter(m => m.mime?.startsWith('video/'));
42
+ const images = media.filter(m => m.mime?.startsWith('image/'));
43
+ const allMedia = media;
44
+
45
+ const m0 = allMedia[0] || null;
46
+ const m1 = allMedia[1] || m0;
47
+ const m2 = allMedia[2] || m0;
48
+ const m3 = allMedia[3] || m1;
49
+ const m4 = allMedia[4] || m2;
50
+ const mVid = videos[0] || null; // testimonial video if available
51
+
52
+ const clientLabel = clientName || 'AI Training';
53
+ const year = new Date().getFullYear();
54
+
55
+ return `<!DOCTYPE html>
56
+ <html><head><meta charset="utf-8">
57
+ <style>
58
+ * { margin:0;padding:0;box-sizing:border-box; }
59
+ body { background:#000;overflow:hidden; }
60
+ #root { position:relative;width:${w}px;height:${h}px;overflow:hidden;background:#000; }
61
+ .scene { position:absolute;inset:0; }
62
+ .overlay-dark { position:absolute;inset:0;background:rgba(0,0,0,0.38); }
63
+ .overlay-gradient-bottom { position:absolute;bottom:0;left:0;right:0;height:420px;
64
+ background:linear-gradient(transparent,rgba(0,0,0,0.75)); }
65
+ #brand-bar { position:absolute;bottom:0;left:0;right:0;padding:18px 48px 30px;
66
+ background:linear-gradient(transparent,rgba(0,0,0,0.72));z-index:100;
67
+ display:flex;align-items:center;gap:10px; }
68
+ </style></head><body>
69
+ <div id="root" data-composition-id="${compId}" data-start="0" data-width="${w}" data-height="${h}">
70
+
71
+ <!-- ─── SCENE 1: HOOK 0–4s ─── -->
72
+ <div id="s1" class="clip scene" data-start="0" data-duration="4" data-track-index="0">
73
+ ${m0 ? mediaEl(m0, 0, 4, 1, 0) : ''}
74
+ <div class="overlay-dark"></div>
75
+ </div>
76
+ <div id="cap-h1" class="clip" data-start="0.4" data-duration="3.3" data-track-index="2"
77
+ style="position:absolute;bottom:560px;left:0;right:0;padding:0 48px;
78
+ text-align:center;font-size:88px;font-weight:900;color:#fff;
79
+ font-family:-apple-system,'Inter',sans-serif;line-height:1.1;
80
+ text-shadow:0 4px 20px rgba(0,0,0,0.9);">
81
+ Ini yang terjadi
82
+ </div>
83
+ <div id="cap-h2" class="clip" data-start="0.7" data-duration="3.0" data-track-index="3"
84
+ style="position:absolute;bottom:440px;left:0;right:0;padding:0 48px;
85
+ text-align:center;font-size:58px;font-weight:700;
86
+ color:rgba(255,255,255,0.85);
87
+ font-family:-apple-system,'Inter',sans-serif;
88
+ text-shadow:0 3px 14px rgba(0,0,0,0.85);">
89
+ ketika tim belajar AI
90
+ </div>
91
+
92
+ <!-- ─── SCENE 2: PROOF 4–8s ─── -->
93
+ <div id="s2" class="clip scene" data-start="4" data-duration="4" data-track-index="4">
94
+ ${m1 ? mediaEl(m1, 4, 4, 5, 0) : '<div style="position:absolute;inset:0;background:#111;"></div>'}
95
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.5);"></div>
96
+ </div>
97
+ <div id="cap-stat" class="clip" data-start="4.4" data-duration="3.3" data-track-index="6"
98
+ style="position:absolute;top:50%;left:0;right:0;transform:translateY(-50%);
99
+ text-align:center;font-family:-apple-system,'Inter',sans-serif;color:#fff;">
100
+ <div style="font-size:104px;font-weight:900;letter-spacing:-2px;line-height:1;
101
+ text-shadow:0 4px 24px rgba(0,0,0,0.9);">1,000+</div>
102
+ <div style="font-size:38px;font-weight:500;opacity:0.85;margin-top:14px;">profesional terlatih</div>
103
+ <div style="font-size:26px;color:rgba(255,255,255,0.6);margin-top:8px;">sejak 2023</div>
104
+ </div>
105
+ <div id="lt" class="clip" data-start="4.7" data-duration="2.9" data-track-index="7"
106
+ style="position:absolute;bottom:160px;left:0;padding:0 0 0 48px;">
107
+ <div style="background:rgba(0,0,0,0.75);backdrop-filter:blur(10px);
108
+ border-left:5px solid #7C6FE0;padding:14px 24px;
109
+ border-radius:0 10px 10px 0;display:inline-block;">
110
+ <div style="font-size:30px;font-weight:700;color:#fff;">${clientLabel}</div>
111
+ <div style="font-size:19px;color:rgba(255,255,255,0.7);">AI Training · ${year}</div>
112
+ </div>
113
+ </div>
114
+
115
+ <!-- ─── SCENE 3: TESTIMONIAL 8–16s ─── -->
116
+ <div id="s3" class="clip scene" data-start="8" data-duration="8" data-track-index="8">
117
+ ${mVid
118
+ ? `<div id="s3vw" style="position:absolute;inset:0;">
119
+ <video data-start="8" data-duration="8" data-track-index="9" data-volume="0.85"
120
+ src="${mVid.rel}" muted playsinline
121
+ style="width:100%;height:100%;object-fit:cover;"></video>
122
+ </div>`
123
+ : `${mediaEl(m2 || m0, 8, 8, 9, 0)}<div class="overlay-dark"></div>`
124
+ }
125
+ <div class="overlay-gradient-bottom"></div>
126
+ </div>
127
+ <div id="cap-testi" class="clip" data-start="8.5" data-duration="7.2" data-track-index="10"
128
+ style="position:absolute;bottom:220px;left:0;right:0;padding:0 48px;
129
+ text-align:center;font-size:52px;font-weight:700;color:#fff;
130
+ font-family:-apple-system,'Inter',sans-serif;line-height:1.3;
131
+ text-shadow:0 3px 16px rgba(0,0,0,0.9);">
132
+ &ldquo;Saya pikir AI itu susah.<br>Ternyata langsung bisa!&rdquo;
133
+ </div>
134
+
135
+ <!-- ─── SCENE 4: ENERGY B-ROLL 16–23s (3 fast cuts) ─── -->
136
+ <div id="s4a" class="clip scene" data-start="16" data-duration="2.5" data-track-index="11">
137
+ ${mediaEl(m0 || m2, 16, 2.5, 12, 0)}
138
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.28);"></div>
139
+ </div>
140
+ <div id="cap-e1" class="clip" data-start="16.3" data-duration="2.0" data-track-index="13"
141
+ style="position:absolute;bottom:280px;left:0;right:0;text-align:center;
142
+ font-size:72px;font-weight:900;color:#fff;
143
+ font-family:-apple-system,'Inter',sans-serif;
144
+ text-shadow:0 4px 16px rgba(0,0,0,0.9);">80% hands-on</div>
145
+
146
+ <div id="s4b" class="clip scene" data-start="18.5" data-duration="2.5" data-track-index="14">
147
+ ${mediaEl(m3 || m1, 18.5, 2.5, 15, 0)}
148
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.28);"></div>
149
+ </div>
150
+ <div id="cap-e2" class="clip" data-start="18.8" data-duration="2.0" data-track-index="16"
151
+ style="position:absolute;bottom:280px;left:0;right:0;text-align:center;
152
+ font-size:72px;font-weight:900;color:#fff;
153
+ font-family:-apple-system,'Inter',sans-serif;
154
+ text-shadow:0 4px 16px rgba(0,0,0,0.9);">Langsung praktek</div>
155
+
156
+ <div id="s4c" class="clip scene" data-start="21" data-duration="2" data-track-index="17">
157
+ ${mediaEl(m4 || m2, 21, 2, 18, 0)}
158
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.28);"></div>
159
+ </div>
160
+ <div id="cap-e3" class="clip" data-start="21.3" data-duration="1.5" data-track-index="19"
161
+ style="position:absolute;bottom:280px;left:0;right:0;text-align:center;
162
+ font-size:72px;font-weight:900;color:#9EF0C8;
163
+ font-family:-apple-system,'Inter',sans-serif;
164
+ text-shadow:0 4px 16px rgba(0,0,0,0.9);">Real output ✓</div>
165
+
166
+ <!-- ─── SCENE 5: CTA 23–28s ─── -->
167
+ <div id="s5" class="clip scene" data-start="23" data-duration="5" data-track-index="20">
168
+ ${mediaEl(m0, 23, 5, 21, 0)}
169
+ <div style="position:absolute;inset:0;background:rgba(8,4,24,0.62);"></div>
170
+ </div>
171
+ <div id="cap-cta1" class="clip" data-start="23.4" data-duration="4.3" data-track-index="22"
172
+ style="position:absolute;top:36%;left:0;right:0;padding:0 48px;
173
+ text-align:center;font-size:76px;font-weight:900;color:#fff;
174
+ font-family:-apple-system,'Inter',sans-serif;line-height:1.15;
175
+ text-shadow:0 4px 20px rgba(0,0,0,0.9);">
176
+ Training AI<br>untuk tim kamu?
177
+ </div>
178
+ <div id="cap-cta2" class="clip" data-start="25" data-duration="2.7" data-track-index="23"
179
+ style="position:absolute;top:58%;left:0;right:0;padding:0 48px;
180
+ text-align:center;font-size:38px;font-weight:500;
181
+ color:rgba(255,255,255,0.75);
182
+ font-family:-apple-system,'Inter',sans-serif;">
183
+ DM · atau klik link di bio
184
+ </div>
185
+
186
+ <!-- ─── SCENE 6: BRAND END CARD 28–30s ─── -->
187
+ <div id="s6" class="clip scene" data-start="28" data-duration="2" data-track-index="24"
188
+ style="background:linear-gradient(135deg,#0d0d1f 0%,#1a1040 50%,#0d0d1f 100%);">
189
+ <div style="position:absolute;inset:0;
190
+ background:radial-gradient(ellipse at center,rgba(124,111,224,0.22) 0%,transparent 70%);"></div>
191
+ </div>
192
+ <div id="end-name" class="clip" data-start="28.2" data-duration="1.8" data-track-index="25"
193
+ style="position:absolute;top:44%;left:0;right:0;text-align:center;
194
+ font-size:66px;font-weight:800;color:#fff;
195
+ font-family:-apple-system,'Inter',sans-serif;">${trainerName}</div>
196
+ <div id="end-tag" class="clip" data-start="28.4" data-duration="1.6" data-track-index="26"
197
+ style="position:absolute;top:52%;left:0;right:0;text-align:center;
198
+ font-size:32px;font-weight:500;color:#9990ee;
199
+ font-family:-apple-system,'Inter',sans-serif;">${tagline}</div>
200
+ <div id="end-web" class="clip" data-start="28.6" data-duration="1.4" data-track-index="27"
201
+ style="position:absolute;top:58.5%;left:0;right:0;text-align:center;
202
+ font-size:24px;color:rgba(255,255,255,0.5);
203
+ font-family:-apple-system,'Inter',sans-serif;">${website}</div>
204
+
205
+ <!-- ─── AUDIO ─── -->
206
+ <audio data-start="0" data-duration="30" data-track-index="50"
207
+ data-volume="0.26" src="./assets/music.mp3"></audio>
208
+
209
+ <!-- ─── PERSISTENT BRAND BAR (not a clip) ─── -->
210
+ <div id="brand-bar">
211
+ <span style="font-size:22px;font-weight:700;color:#fff;font-family:-apple-system,sans-serif;">
212
+ ${trainerName}</span>
213
+ <span style="font-size:15px;color:rgba(255,255,255,0.6);font-family:-apple-system,sans-serif;">
214
+ · ${tagline}</span>
215
+ </div>
216
+
217
+ <!-- ─── GSAP ANIMATIONS ─── -->
218
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
219
+ <script>
220
+ const tl = gsap.timeline({ paused: true });
221
+
222
+ // Scene 1: Hook
223
+ tl.from("#cap-h1", { y:50, opacity:0, duration:0.45, ease:"power3.out" }, 0.4);
224
+ tl.from("#cap-h2", { y:35, opacity:0, duration:0.4, ease:"power3.out" }, 0.7);
225
+ tl.to("#s1", { opacity:0, duration:0.3 }, 3.7);
226
+
227
+ // Scene 2: Proof
228
+ tl.from("#s2", { opacity:0, duration:0.3 }, 4);
229
+ tl.from("#cap-stat", { scale:0.72, opacity:0, duration:0.5, ease:"back.out(1.7)" }, 4.4);
230
+ tl.from("#lt", { x:-320, opacity:0, duration:0.4, ease:"power3.out" }, 4.7);
231
+ tl.to("#lt", { x:-320, opacity:0, duration:0.3, ease:"power2.in" }, 7.3);
232
+ tl.to("#s2", { opacity:0, duration:0.35 }, 7.65);
233
+
234
+ // Scene 3: Testimonial
235
+ tl.from("#s3", { opacity:0, duration:0.35 }, 8);
236
+ tl.from("#cap-testi", { y:40, opacity:0, duration:0.45, ease:"power3.out" }, 8.5);
237
+ tl.to("#cap-testi", { opacity:0, duration:0.3 }, 15.4);
238
+ tl.to("#s3", { opacity:0, duration:0.35 }, 15.65);
239
+
240
+ // Scene 4a: Energy cut 1
241
+ tl.from("#s4a", { opacity:0, duration:0.18 }, 16);
242
+ tl.from("#cap-e1",{ y:28, opacity:0, duration:0.28, ease:"power2.out" }, 16.3);
243
+ tl.to("#s4a", { opacity:0, duration:0.18 }, 18.32);
244
+
245
+ // Scene 4b: Energy cut 2
246
+ tl.from("#s4b", { opacity:0, duration:0.18 }, 18.5);
247
+ tl.from("#cap-e2",{ y:28, opacity:0, duration:0.28, ease:"power2.out" }, 18.8);
248
+ tl.to("#s4b", { opacity:0, duration:0.18 }, 20.82);
249
+
250
+ // Scene 4c: Energy cut 3 — scale pop
251
+ tl.from("#s4c", { opacity:0, duration:0.15 }, 21);
252
+ tl.from("#cap-e3",{ scale:0.78, opacity:0, duration:0.3, ease:"back.out(2)" }, 21.3);
253
+ tl.to("#s4c", { opacity:0, duration:0.22 }, 22.78);
254
+
255
+ // Scene 5: CTA
256
+ tl.from("#s5", { opacity:0, duration:0.38 }, 23);
257
+ tl.from("#cap-cta1",{ y:42, opacity:0, duration:0.5, ease:"power3.out" }, 23.4);
258
+ tl.from("#cap-cta2",{ y:26, opacity:0, duration:0.45, ease:"power3.out" }, 25);
259
+ tl.to("#s5", { opacity:0, duration:0.4 }, 27.6);
260
+
261
+ // Scene 6: Brand end card
262
+ tl.from("#s6", { opacity:0, duration:0.4 }, 28);
263
+ tl.from("#end-name",{ y:30, opacity:0, duration:0.45, ease:"power3.out" }, 28.2);
264
+ tl.from("#end-tag", { y:22, opacity:0, duration:0.4, ease:"power3.out" }, 28.4);
265
+ tl.from("#end-web", { y:16, opacity:0, duration:0.4, ease:"power3.out" }, 28.6);
266
+
267
+ // CRITICAL: extend timeline to full duration
268
+ tl.set({}, {}, ${dur});
269
+
270
+ // CRITICAL: register with exact data-composition-id
271
+ window.__timelines = window.__timelines || {};
272
+ window.__timelines["${compId}"] = tl;
273
+ </script>
274
+ </div></body></html>`;
275
  },
276
  };
277
 
278
+ // ── TEASER (30s · 9:16) ─────────────────────────────────────────────────────
279
+ const teaser = {
280
+ meta: {
281
+ name: 'Event Teaser', icon: '',
282
+ description: '30s fast-cut teaser. 9:16 vertical.',
283
+ format: '9:16', duration: 30, music: 'energetic',
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  },
285
+ buildHTML(media, config, compId, w, h, dur) {
286
+ const { clientName = 'Next Event', trainerName = 'Dee Ferdinand',
287
+ tagline = 'AI Corporate Trainer', website = 'deeferdinand.com' } = config;
288
+ const m = (i) => media[i % Math.max(media.length,1)] || media[0];
289
 
290
+ return `<!DOCTYPE html>
291
+ <html><head><meta charset="utf-8">
292
+ <style>
293
+ * { margin:0;padding:0;box-sizing:border-box; }
294
+ body { background:#000;overflow:hidden; }
295
+ #root { position:relative;width:${w}px;height:${h}px;overflow:hidden;background:#000; }
296
+ .s { position:absolute;inset:0; }
297
+ #bb { position:absolute;bottom:0;left:0;right:0;padding:18px 48px 30px;
298
+ background:linear-gradient(transparent,rgba(0,0,0,0.72));z-index:100; }
299
+ </style></head><body>
300
+ <div id="root" data-composition-id="${compId}" data-start="0" data-width="${w}" data-height="${h}">
301
+
302
+ <div id="s1" class="clip s" data-start="0" data-duration="3" data-track-index="0">
303
+ ${m(0) ? `<img src="${m(0).rel}" style="width:100%;height:100%;object-fit:cover;">` : ''}
304
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.35);"></div>
305
+ </div>
306
+ <div id="c1" class="clip" data-start="0.3" data-duration="2.5" data-track-index="1"
307
+ style="position:absolute;top:42%;left:0;right:0;text-align:center;
308
+ font-size:100px;font-weight:900;color:#fff;
309
+ font-family:-apple-system,sans-serif;letter-spacing:-2px;
310
+ text-shadow:0 4px 20px rgba(0,0,0,0.9);">COMING<br>SOON</div>
311
+
312
+ <div id="s2" class="clip s" data-start="3" data-duration="3" data-track-index="2">
313
+ ${m(1) ? `<img src="${m(1).rel}" style="width:100%;height:100%;object-fit:cover;">` : ''}
314
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.3);"></div>
315
+ </div>
316
+ <div id="c2" class="clip" data-start="3.3" data-duration="2.4" data-track-index="3"
317
+ style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
318
+ font-size:68px;font-weight:900;color:#fff;
319
+ font-family:-apple-system,sans-serif;
320
+ text-shadow:0 3px 16px rgba(0,0,0,0.9);">${clientName}</div>
321
+
322
+ <div id="s3" class="clip s" data-start="6" data-duration="3" data-track-index="4">
323
+ ${m(2) ? `<img src="${m(2).rel}" style="width:100%;height:100%;object-fit:cover;">` : ''}
324
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.3);"></div>
325
+ </div>
326
+ <div id="c3" class="clip" data-start="6.3" data-duration="2.4" data-track-index="5"
327
+ style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
328
+ font-size:68px;font-weight:900;color:#fff;
329
+ font-family:-apple-system,sans-serif;
330
+ text-shadow:0 3px 16px rgba(0,0,0,0.9);">AI Training</div>
331
+
332
+ <div id="s4" class="clip s" data-start="9" data-duration="3" data-track-index="6">
333
+ ${m(0) ? `<img src="${m(0).rel}" style="width:100%;height:100%;object-fit:cover;">` : ''}
334
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.3);"></div>
335
+ </div>
336
+ <div id="c4" class="clip" data-start="9.3" data-duration="2.4" data-track-index="7"
337
+ style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
338
+ font-size:68px;font-weight:900;color:#9EF0C8;
339
+ font-family:-apple-system,sans-serif;
340
+ text-shadow:0 3px 16px rgba(0,0,0,0.9);">Hands-on. Now.</div>
341
+
342
+ <div id="s5" class="clip s" data-start="12" data-duration="3" data-track-index="8">
343
+ ${m(1) ? `<img src="${m(1).rel}" style="width:100%;height:100%;object-fit:cover;">` : ''}
344
+ <div style="position:absolute;inset:0;background:rgba(0,0,0,0.3);"></div>
345
+ </div>
346
+ <div id="c5" class="clip" data-start="12.3" data-duration="2.4" data-track-index="9"
347
+ style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
348
+ font-size:68px;font-weight:900;color:#fff;
349
+ font-family:-apple-system,sans-serif;
350
+ text-shadow:0 3px 16px rgba(0,0,0,0.9);">Real results.</div>
351
+
352
+ <div id="s6" class="clip s" data-start="15" data-duration="12" data-track-index="10">
353
+ ${m(2) ? `<img src="${m(2).rel}" style="width:100%;height:100%;object-fit:cover;">` : ''}
354
+ <div style="position:absolute;inset:0;background:rgba(8,4,24,0.6);"></div>
355
+ </div>
356
+ <div id="c6" class="clip" data-start="15.4" data-duration="6" data-track-index="11"
357
+ style="position:absolute;top:38%;left:0;right:0;text-align:center;
358
+ font-size:80px;font-weight:900;color:#fff;
359
+ font-family:-apple-system,sans-serif;line-height:1.2;
360
+ text-shadow:0 4px 20px rgba(0,0,0,0.9);">Save your spot.<br>Link in bio.</div>
361
+
362
+ <div id="se" class="clip s" data-start="27" data-duration="3" data-track-index="12"
363
+ style="background:linear-gradient(135deg,#0d0d1f,#1a1040,#0d0d1f);">
364
+ <div style="position:absolute;inset:0;background:radial-gradient(ellipse at center,rgba(124,111,224,0.2),transparent 70%);"></div>
365
+ </div>
366
+ <div id="en" class="clip" data-start="27.2" data-duration="2.8" data-track-index="13"
367
+ style="position:absolute;top:44%;left:0;right:0;text-align:center;
368
+ font-size:60px;font-weight:800;color:#fff;font-family:-apple-system,sans-serif;">${trainerName}</div>
369
+ <div id="et" class="clip" data-start="27.4" data-duration="2.6" data-track-index="14"
370
+ style="position:absolute;top:52%;left:0;right:0;text-align:center;
371
+ font-size:30px;color:#9990ee;font-family:-apple-system,sans-serif;">${tagline}</div>
372
+
373
+ <audio data-start="0" data-duration="30" data-track-index="50" data-volume="0.3"
374
+ src="./assets/music.mp3"></audio>
375
+
376
+ <div id="bb">
377
+ <span style="font-size:22px;font-weight:700;color:#fff;font-family:-apple-system,sans-serif;">${trainerName}</span>
378
+ <span style="font-size:15px;color:rgba(255,255,255,0.6);font-family:-apple-system,sans-serif;"> · ${tagline}</span>
379
+ </div>
380
+
381
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
382
+ <script>
383
+ const tl = gsap.timeline({ paused: true });
384
+ tl.from("#c1", { scale:0.75, opacity:0, duration:0.4, ease:"back.out(1.7)" }, 0.3);
385
+ tl.to("#s1", { opacity:0, duration:0.2 }, 2.8);
386
+ tl.from("#s2", { opacity:0, duration:0.2 }, 3);
387
+ tl.from("#c2", { y:30, opacity:0, duration:0.3, ease:"power2.out" }, 3.3);
388
+ tl.to("#s2", { opacity:0, duration:0.2 }, 5.8);
389
+ tl.from("#s3", { opacity:0, duration:0.2 }, 6);
390
+ tl.from("#c3", { y:30, opacity:0, duration:0.3, ease:"power2.out" }, 6.3);
391
+ tl.to("#s3", { opacity:0, duration:0.2 }, 8.8);
392
+ tl.from("#s4", { opacity:0, duration:0.2 }, 9);
393
+ tl.from("#c4", { scale:0.8, opacity:0, duration:0.3, ease:"back.out(2)" }, 9.3);
394
+ tl.to("#s4", { opacity:0, duration:0.2 }, 11.8);
395
+ tl.from("#s5", { opacity:0, duration:0.2 }, 12);
396
+ tl.from("#c5", { y:30, opacity:0, duration:0.3, ease:"power2.out" }, 12.3);
397
+ tl.to("#s5", { opacity:0, duration:0.2 }, 14.8);
398
+ tl.from("#s6", { opacity:0, duration:0.3 }, 15);
399
+ tl.from("#c6", { y:40, opacity:0, duration:0.5, ease:"power3.out" }, 15.4);
400
+ tl.to("#s6", { opacity:0, duration:0.35 }, 26.65);
401
+ tl.from("#se", { opacity:0, duration:0.35 }, 27);
402
+ tl.from("#en", { y:25, opacity:0, duration:0.4, ease:"power3.out" }, 27.2);
403
+ tl.from("#et", { y:18, opacity:0, duration:0.35, ease:"power3.out" }, 27.4);
404
+ tl.set({}, {}, ${dur});
405
+ window.__timelines = window.__timelines || {};
406
+ window.__timelines["${compId}"] = tl;
407
+ </script>
408
+ </div></body></html>`;
409
  },
410
  };
411
 
412
+ // Trailer and Community reuse testimonial pattern
413
+ const trailer = { ...testimonial, meta: { ...testimonial.meta, name: 'Cinematic Trailer', icon: '🎬', duration: 60 } };
414
+ const community = { ...testimonial, meta: { ...testimonial.meta, name: 'Community Story', icon: '🤝' } };
415
+
416
  export const WORKFLOWS = { testimonial, teaser, trailer, community };