AIGoose commited on
Commit
0624752
·
1 Parent(s): 96ae60b

feat: add HyperFrames testimonial pipeline — composition, workflow, patcher, README

Browse files
.github/workflows/render-testimonial.yml ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Render Testimonial Video
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ gdrive_file_id:
7
+ description: 'Google Drive file ID of testimonial MOV'
8
+ required: true
9
+ default: '1f1qlgyLJSCQgpjrWflaqldsNE2fZ-_1X'
10
+ participant_name:
11
+ description: 'Participant name (lower-third)'
12
+ required: false
13
+ default: 'Peserta Training'
14
+ participant_role:
15
+ description: 'Participant role / org'
16
+ required: false
17
+ default: 'AI Transformation Series - Batch 2026'
18
+ pull_quote:
19
+ description: 'Pull quote text (Indonesian)'
20
+ required: false
21
+ default: 'Setelah training ini, tim kami langsung bisa praktek dan hasilnya nyata.'
22
+
23
+ jobs:
24
+ render:
25
+ runs-on: ubuntu-latest
26
+ timeout-minutes: 30
27
+
28
+ steps:
29
+ - name: Checkout repo
30
+ uses: actions/checkout@v4
31
+
32
+ - name: Install system dependencies
33
+ run: |
34
+ sudo apt-get update -qq
35
+ sudo apt-get install -y ffmpeg python3-pip
36
+ pip3 install gdown huggingface_hub --quiet
37
+
38
+ - name: Setup Node.js 22
39
+ uses: actions/setup-node@v4
40
+ with:
41
+ node-version: '22'
42
+
43
+ - name: Install HyperFrames CLI
44
+ run: npm install -g hyperframes
45
+
46
+ - name: Install Chromium
47
+ run: sudo apt-get install -y chromium-browser || true
48
+
49
+ - name: Download testimonial MOV from Google Drive
50
+ run: |
51
+ mkdir -p assets
52
+ python3 - <<'EOF'
53
+ import gdown, os, sys
54
+ fid = "${{ github.event.inputs.gdrive_file_id }}"
55
+ out = "assets/testimoni_raw.MOV"
56
+ gdown.download(f"https://drive.google.com/uc?id={fid}", out, quiet=False)
57
+ if not os.path.exists(out):
58
+ sys.exit(1)
59
+ print(f"Downloaded {os.path.getsize(out)/1024/1024:.1f} MB")
60
+ EOF
61
+
62
+ - name: Convert MOV to MP4
63
+ run: |
64
+ ffmpeg -y -i assets/testimoni_raw.MOV \
65
+ -c:v libx264 -preset fast -crf 22 \
66
+ -c:a aac -b:a 192k -movflags +faststart \
67
+ assets/testimoni.mp4
68
+ echo "Size: $(du -sh assets/testimoni.mp4)"
69
+
70
+ - name: Get video duration
71
+ run: |
72
+ DUR=$(ffprobe -v quiet -show_entries format=duration \
73
+ -of default=noprint_wrappers=1:nokey=1 assets/testimoni.mp4 | cut -d. -f1)
74
+ echo "VIDEO_DURATION=$DUR" >> $GITHUB_ENV
75
+ echo "Duration: ${DUR}s"
76
+
77
+ - name: Patch composition with participant data
78
+ run: |
79
+ python3 scripts/patch_composition.py \
80
+ --input compositions/testimonial.html \
81
+ --output compositions/testimonial_render.html \
82
+ --name "${{ github.event.inputs.participant_name }}" \
83
+ --role "${{ github.event.inputs.participant_role }}" \
84
+ --quote "${{ github.event.inputs.pull_quote }}"
85
+ sed -i "s/data-duration=\"90\"/data-duration=\"${VIDEO_DURATION}\"/" \
86
+ compositions/testimonial_render.html
87
+
88
+ - name: Lint composition
89
+ run: npx hyperframes lint compositions/testimonial_render.html || true
90
+
91
+ - name: Render to MP4
92
+ run: |
93
+ mkdir -p output
94
+ TS=$(date +%Y%m%d-%H%M)
95
+ OUT="output/dee-testimonial-${TS}.mp4"
96
+ echo "OUTPUT_FILE=$OUT" >> $GITHUB_ENV
97
+ npx hyperframes render compositions/testimonial_render.html \
98
+ --output "$OUT" --width 1080 --height 1920 --fps 30
99
+ echo "Rendered: $OUT ($(du -sh $OUT | cut -f1))"
100
+
101
+ - name: Upload to Hugging Face dataset
102
+ env:
103
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
104
+ run: |
105
+ python3 - <<'EOF'
106
+ import os
107
+ from huggingface_hub import HfApi
108
+ api = HfApi(token=os.environ["HF_TOKEN"])
109
+ out = os.environ["OUTPUT_FILE"]
110
+ fname = os.path.basename(out)
111
+ api.upload_file(
112
+ path_or_fileobj=out,
113
+ path_in_repo=f"renders/{fname}",
114
+ repo_id="AIgoose/video-renders",
115
+ repo_type="dataset",
116
+ )
117
+ url = f"https://huggingface.co/datasets/AIgoose/video-renders/resolve/main/renders/{fname}"
118
+ print(f"Video ready: {url}")
119
+ print(f"::notice title=Video Ready::{url}")
120
+ EOF
121
+
122
+ - name: Upload artifact (backup)
123
+ uses: actions/upload-artifact@v4
124
+ with:
125
+ name: testimonial-mp4
126
+ path: output/*.mp4
127
+ retention-days: 7
README.md CHANGED
@@ -1,96 +1,58 @@
1
- ---
2
- title: Hyperframes Video Studio
3
- emoji: 🎬
4
- colorFrom: purple
5
- colorTo: pink
6
- sdk: docker
7
- app_port: 7860
8
- pinned: true
9
- ---
10
-
11
- # 🎬 Hyperframes Video Studio
12
-
13
- **Cloud video production studio powered by HyperFrames — Testimonial · Teaser · Trailer generator**
14
 
15
- Upload photos + videos from any client event → select a workflow → render a production-ready MP4 with captions and background music. No local setup required.
16
-
17
- 🔗 **Live:** https://aigoose-hyperframes-video-studio.hf.space
18
 
19
  ---
20
 
21
- ## Workflows
22
-
23
- | Workflow | Duration | Format | Best For |
24
- |---|---|---|---|
25
- | 🏢 Corporate Testimonial | 75s | 9:16 | Corporate AI training social proof |
26
- | ⚡ Event Teaser | 30s | 9:16 | Upcoming event announcement |
27
- | 🎬 Cinematic Trailer | 60s | 9:16/16:9 | Program recap, brand video |
28
- | 🤝 Community Story | 60s | 9:16 | Community/church/NGO sessions |
29
-
30
- ---
31
 
32
- ## How It Works
33
-
34
- 1. **Upload** photos and videos from your event (drag & drop)
35
- 2. **Select** workflow type (Testimonial / Teaser / Trailer / Community)
36
- 3. **Fill** project details — client name, trainer name, tagline
37
- 4. **Choose** background music (auto-matched or manual)
38
- 5. **Render** HyperFrames builds the composition and encodes MP4
39
- 6. **Download** the finished video
40
 
41
  ---
42
 
43
- ## Tech Stack
44
 
45
- - **[HyperFrames](https://github.com/heygen-com/hyperframes)** (heygen-com) — HTML MP4 rendering engine, Apache 2.0
46
- - **[FFmpeg](https://github.com/FFmpeg/FFmpeg)** — video encoding
47
- - **Chromium** — headless browser for frame rendering
48
- - **Node.js + Express** — REST API server
49
- - **WebSocket** — real-time render progress
50
- - **Pixabay Music** — 6 royalty-free tracks (free commercial use)
51
 
52
- ## Reference Repos
 
 
 
 
 
53
 
54
- | Repo | Purpose |
55
- |---|---|
56
- | [heygen-com/hyperframes](https://github.com/heygen-com/hyperframes) | Core HTML→MP4 engine |
57
- | [heygen-com/hyperframes-launches](https://github.com/heygen-com/hyperframes-launches) | Production composition examples |
58
- | [calesthio/OpenMontage](https://github.com/calesthio/OpenMontage) | Agentic video pipeline patterns |
59
- | [mifi/editly](https://github.com/mifi/editly) | Declarative clip assembly |
60
- | [wzk1015/video-bgm-generation](https://github.com/wzk1015/video-bgm-generation) | AI video→music matching |
61
 
62
  ---
63
 
64
- ## Background Music Library
65
-
66
- | Track | Mood | BPM | Auto-assigned to |
67
- |---|---|---|---|
68
- | corporate-motivation.mp3 | Corporate | 120 | Testimonial |
69
- | inspiring-cinematic.mp3 | Cinematic | 90 | Trailer |
70
- | upbeat-community.mp3 | Upbeat | 128 | Community |
71
- | warm-documentary.mp3 | Warm | 75 | Community Story |
72
- | tech-corporate.mp3 | Tech | 110 | Teaser |
73
- | action-sport.mp3 | Energetic | 145 | Event Teaser |
74
 
75
- All tracks: Pixabay License free commercial use, no attribution required.
 
76
 
77
  ---
78
 
79
- ## API
80
 
81
- ```bash
82
- GET /api/health # Health check
83
- GET /api/workflows # List workflows
84
- GET /api/music # List music tracks
85
- POST /api/render # Start render job (multipart/form-data)
86
- GET /api/jobs/:id # Poll job status
87
- GET /api/download/:id # Download MP4
88
- ```
89
-
90
- WebSocket: `wss://aigoose-hyperframes-video-studio.hf.space?job=JOB_ID`
91
 
92
  ---
93
 
94
- Built by Dee Ferdinand × Claude (Anthropic). HyperFrames by HeyGen — Apache 2.0.
95
-
96
- <!-- rebuild-trigger: 1781584816 -->
 
1
+ # dee-video-studio
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ **HyperFrames x GitHub Actions x Hugging Face**
4
+ Testimonial video pipeline for Dee Ferdinand AI / MAIN AI.
5
+ No HeyGen subscription. No paid API. 100% open-source.
6
 
7
  ---
8
 
9
+ ## Pipeline
 
 
 
 
 
 
 
 
 
10
 
11
+ ```
12
+ Google Drive MOV
13
+ gdown download (GitHub Actions)
14
+ ffmpeg MOV→MP4
15
+ → patch_composition.py (inject name/role/quote)
16
+ npx hyperframes render (headless Chrome + ffmpeg)
17
+ HuggingFace dataset: AIgoose/video-renders
18
+ ```
19
 
20
  ---
21
 
22
+ ## Run a render
23
 
24
+ **ActionsRender Testimonial Video Run workflow**
 
 
 
 
 
25
 
26
+ | Input | What to fill |
27
+ |-------|-------------|
28
+ | `gdrive_file_id` | The ID from the Drive share URL |
29
+ | `participant_name` | For the lower-third (e.g. Budi Santoso) |
30
+ | `participant_role` | Role line (e.g. Head of Digital · PT Maju) |
31
+ | `pull_quote` | Strongest spoken line in Indonesian |
32
 
33
+ Output lands at:
34
+ `https://huggingface.co/datasets/AIgoose/video-renders/resolve/main/renders/<filename>.mp4`
 
 
 
 
 
35
 
36
  ---
37
 
38
+ ## One-time setup
 
 
 
 
 
 
 
 
 
39
 
40
+ 1. **GitHub Secret**: Settings Secrets `HF_TOKEN` (your HF write token from hf.co/settings/tokens)
41
+ 2. **Drive permission**: Set the MOV to "Anyone with the link" → Viewer
42
 
43
  ---
44
 
45
+ ## Composition overlay map
46
 
47
+ | Overlay | Timing | Description |
48
+ |---------|--------|-------------|
49
+ | Name card | 0–4s | Program name + brand |
50
+ | Lower-third | 8–18s | Participant name + role |
51
+ | Pull quote | 30–48s | Strongest spoken line |
52
+ | Stat callout | 52–64s | 1,000+ professionals trained |
53
+ | Feature chips | 66–78s | 80% hands-on, langsung praktek, real output |
54
+ | Brand outro | 83–90s | Dee Ferdinand AI CTA |
 
 
55
 
56
  ---
57
 
58
+ HyperFrames: https://github.com/heygen-com/hyperframes
 
 
compositions/testimonial.html ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <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">
6
+ <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { background: #0d0d1f; overflow: hidden; }
10
+ #root { position: relative; width: 1080px; height: 1920px; overflow: hidden; background: #0d0d1f; }
11
+ .clip { position: absolute; }
12
+ .sc { inset: 0; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <div id="root"
17
+ data-composition-id="dee-testimonial"
18
+ data-start="0"
19
+ data-duration="90"
20
+ data-width="1080"
21
+ data-height="1920">
22
+
23
+ <!-- A-ROLL: original footage (muted video + separate audio element) -->
24
+ <video id="aroll-video" class="clip sc" src="../assets/testimoni.mp4"
25
+ data-start="0" data-duration="90" data-track-index="0"
26
+ muted playsinline style="object-fit:cover;width:100%;height:100%;"></video>
27
+
28
+ <audio id="aroll-audio" src="../assets/testimoni.mp4"
29
+ data-start="0" data-duration="90" data-track-index="10" data-volume="1.0"></audio>
30
+
31
+ <!-- Bottom vignette (persistent) -->
32
+ <div style="position:absolute;inset:0;pointer-events:none;
33
+ background:linear-gradient(to top,rgba(0,0,0,0.75) 0%,transparent 45%);z-index:5;"></div>
34
+
35
+ <!-- SCENE 1: NAME CARD 0-4s -->
36
+ <div id="s1-bg" class="clip sc" data-start="0" data-duration="4" data-track-index="1"
37
+ style="background:linear-gradient(180deg,rgba(13,13,31,0.72) 0%,transparent 40%);z-index:6;"></div>
38
+ <div id="s1-rule" class="clip" data-start="0.3" data-duration="3.5" data-track-index="2"
39
+ style="position:absolute;top:80px;left:56px;width:56px;height:3px;background:#7C6FE0;z-index:7;"></div>
40
+ <div id="s1-name" class="clip" data-start="0.4" data-duration="3.4" data-track-index="2"
41
+ style="position:absolute;top:96px;left:56px;right:56px;
42
+ font:700 72px/1.1 'Space Grotesk',sans-serif;color:#fff;letter-spacing:-1px;z-index:7;">
43
+ AI Transformation<br>Series</div>
44
+ <div id="s1-tag" class="clip" data-start="0.6" data-duration="3.2" data-track-index="2"
45
+ style="position:absolute;top:238px;left:56px;
46
+ font:500 32px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.65);z-index:7;">
47
+ Dee Ferdinand AI &middot; MAIN AI</div>
48
+
49
+ <!-- SCENE 2: LOWER-THIRD BAR 8-18s -->
50
+ <div id="s2-card" class="clip" data-start="8" data-duration="10" data-track-index="3"
51
+ style="position:absolute;bottom:180px;left:0;background:rgba(10,10,25,0.88);
52
+ border-left:6px solid #7C6FE0;border-radius:0 16px 16px 0;padding:20px 36px 20px 50px;z-index:8;">
53
+ <div style="font:700 40px/1.2 'Plus Jakarta Sans',sans-serif;color:#fff;">Peserta Training</div>
54
+ <div style="font:400 24px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.60);margin-top:6px;">
55
+ AI Transformation Series &middot; Batch 2026</div>
56
+ </div>
57
+
58
+ <!-- SCENE 3: PULL QUOTE 30-48s -->
59
+ <div id="s3-quote-bg" class="clip" data-start="30" data-duration="18" data-track-index="4"
60
+ style="position:absolute;bottom:220px;left:40px;right:40px;background:rgba(10,10,25,0.90);
61
+ border-radius:20px;padding:40px 44px;z-index:9;">
62
+ <div style="font:600 56px/1.3 'Plus Jakarta Sans',sans-serif;color:#fff;font-style:italic;">
63
+ &ldquo;Setelah training ini, tim kami langsung bisa praktek dan hasilnya nyata.&rdquo;</div>
64
+ <div style="margin-top:24px;font:400 28px/1 'Plus Jakarta Sans',sans-serif;color:#9EF0C8;">
65
+ &mdash; Peserta &middot; AI Transformation Series</div>
66
+ </div>
67
+
68
+ <!-- SCENE 4: STAT CALLOUT 52-64s -->
69
+ <div id="s4-stat" class="clip" data-start="52" data-duration="12" data-track-index="5"
70
+ style="position:absolute;top:80px;right:40px;background:rgba(124,111,224,0.18);
71
+ border:1.5px solid rgba(124,111,224,0.5);border-radius:16px;padding:28px 36px;
72
+ text-align:center;z-index:8;">
73
+ <div style="font:900 88px/1 'Space Grotesk',sans-serif;color:#7C6FE0;">1,000+</div>
74
+ <div style="font:500 28px/1.3 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.75);margin-top:10px;">
75
+ profesional terlatih<br>sejak 2023</div>
76
+ </div>
77
+
78
+ <!-- SCENE 5: FEATURE CHIPS 66-78s -->
79
+ <div id="s5-chip1" class="clip" data-start="66" data-duration="12" data-track-index="6"
80
+ style="position:absolute;bottom:380px;left:40px;background:#7C6FE0;border-radius:40px;padding:16px 32px;z-index:8;">
81
+ <span style="font:700 30px/1 'Plus Jakarta Sans',sans-serif;color:#fff;">80% hands-on</span></div>
82
+ <div id="s5-chip2" class="clip" data-start="67" data-duration="11" data-track-index="6"
83
+ style="position:absolute;bottom:310px;left:40px;background:rgba(158,240,200,0.15);
84
+ border:1.5px solid #9EF0C8;border-radius:40px;padding:16px 32px;z-index:8;">
85
+ <span style="font:700 30px/1 'Plus Jakarta Sans',sans-serif;color:#9EF0C8;">Langsung praktek</span></div>
86
+ <div id="s5-chip3" class="clip" data-start="68" data-duration="10" data-track-index="6"
87
+ style="position:absolute;bottom:240px;left:40px;background:rgba(224,111,154,0.15);
88
+ border:1.5px solid #E06F9A;border-radius:40px;padding:16px 32px;z-index:8;">
89
+ <span style="font:700 30px/1 'Plus Jakarta Sans',sans-serif;color:#E06F9A;">Real output &#x2713;</span></div>
90
+
91
+ <!-- SCENE 6: BRAND OUTRO 83-90s -->
92
+ <div id="s6-overlay" class="clip sc" data-start="83" data-duration="7" data-track-index="7"
93
+ style="background:linear-gradient(180deg,rgba(13,13,31,0) 0%,rgba(13,13,31,0.96) 60%);z-index:10;"></div>
94
+ <div id="s6-name" class="clip" data-start="83.5" data-duration="6.5" data-track-index="7"
95
+ style="position:absolute;bottom:320px;left:0;right:0;text-align:center;
96
+ font:800 72px/1.1 'Space Grotesk',sans-serif;color:#fff;z-index:11;">Dee Ferdinand</div>
97
+ <div id="s6-title" class="clip" data-start="84" data-duration="6" data-track-index="7"
98
+ style="position:absolute;bottom:260px;left:0;right:0;text-align:center;
99
+ font:500 36px/1 'Plus Jakarta Sans',sans-serif;color:#9990ee;z-index:11;">
100
+ AI Corporate Trainer &middot; MAIN AI</div>
101
+ <div id="s6-handle" class="clip" data-start="84.5" data-duration="5.5" data-track-index="7"
102
+ style="position:absolute;bottom:210px;left:0;right:0;text-align:center;
103
+ font:400 28px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.48);z-index:11;">
104
+ @deeferdinand &middot; deeferdinand.com</div>
105
+ <div id="s6-cta" class="clip" data-start="85" data-duration="5" data-track-index="7"
106
+ style="position:absolute;bottom:140px;left:0;right:0;text-align:center;
107
+ font:600 32px/1 'Plus Jakarta Sans',sans-serif;font-style:italic;color:#7C6FE0;z-index:11;">
108
+ Transform your team &#x2192;</div>
109
+
110
+ <!-- Persistent brand bar -->
111
+ <div style="position:absolute;bottom:0;left:0;right:0;padding:20px 56px 40px;
112
+ background:linear-gradient(transparent,rgba(0,0,0,0.60));z-index:6;">
113
+ <span style="font:700 26px/1 'Space Grotesk',sans-serif;color:#fff;">Dee Ferdinand</span>
114
+ <span style="font:400 18px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.55);margin-left:8px;">&middot; AI Corporate Trainer</span>
115
+ </div>
116
+
117
+ <!-- Grain overlay -->
118
+ <div style="position:absolute;inset:0;pointer-events:none;z-index:99;overflow:hidden;">
119
+ <div style="position:absolute;top:-50%;left:-50%;width:200%;height:200%;opacity:0.055;
120
+ background:url('data:image/svg+xml,%3Csvg viewBox=%270 0 256 256%27 xmlns=%27http://www.w3.org/2000/svg%27%3E%3Cfilter id=%27n%27%3E%3CfeTurbulence type=%27fractalNoise%27 baseFrequency=%270.65%27 numOctaves=%273%27 stitchTiles=%27stitch%27/%3E%3C/filter%3E%3Crect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23n)%27/%3E%3C/svg%3E');"></div>
121
+ </div>
122
+ </div>
123
+
124
+ <script>
125
+ const tl = gsap.timeline({ paused: true });
126
+ tl.from("#s1-rule", { scaleX:0, transformOrigin:"left", duration:0.25, ease:"expo.out" }, 0.3);
127
+ tl.from("#s1-name", { y:-80, autoAlpha:0, duration:0.28, ease:"expo.out" }, 0.4);
128
+ tl.from("#s1-tag", { y:40, autoAlpha:0, duration:0.35, ease:"power3.out" }, 0.6);
129
+ tl.from("#s2-card", { x:-500, autoAlpha:0, duration:0.40, ease:"power3.out" }, 8);
130
+ tl.from("#s3-quote-bg", { y:60, autoAlpha:0, scale:0.96, duration:0.55, ease:"back.out(1.8)" }, 30);
131
+ tl.to("#s3-quote-bg", { autoAlpha:0, duration:0.4, ease:"sine.inOut" }, 47);
132
+ tl.from("#s4-stat", { y:-60, autoAlpha:0, scale:0.85, duration:0.45, ease:"back.out(2.2)" }, 52);
133
+ tl.to("#s4-stat", { autoAlpha:0, duration:0.3 }, 63.5);
134
+ tl.from("#s5-chip1", { x:-120, autoAlpha:0, duration:0.30, ease:"power4.out" }, 66);
135
+ tl.from("#s5-chip2", { x:-120, autoAlpha:0, duration:0.30, ease:"power4.out" }, 66.2);
136
+ tl.from("#s5-chip3", { x:-120, autoAlpha:0, duration:0.30, ease:"power4.out" }, 66.4);
137
+ tl.to(["#s5-chip1","#s5-chip2","#s5-chip3"], { autoAlpha:0, duration:0.3 }, 77.5);
138
+ tl.from("#s6-overlay", { autoAlpha:0, duration:0.6, ease:"sine.inOut" }, 83);
139
+ tl.from("#s6-name", { y:40, autoAlpha:0, duration:0.45, ease:"power3.out" }, 83.5);
140
+ tl.from("#s6-title", { y:30, autoAlpha:0, duration:0.40, ease:"power3.out" }, 84);
141
+ tl.from("#s6-handle", { y:20, autoAlpha:0, duration:0.35, ease:"power3.out" }, 84.5);
142
+ tl.from("#s6-cta", { y:20, autoAlpha:0, duration:0.35, ease:"power3.out" }, 85);
143
+ tl.set({}, {}, 90);
144
+ window.__timelines = window.__timelines || {};
145
+ window.__timelines["dee-testimonial"] = tl;
146
+ </script>
147
+ </div>
148
+ </body>
149
+ </html>
scripts/patch_composition.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """patch_composition.py — inject participant name/role/quote before render"""
3
+ import argparse, re
4
+
5
+ def patch(html, name, role, quote):
6
+ html = re.sub(r'Peserta Training', name, html)
7
+ html = re.sub(r'AI Transformation Series &middot; Batch 2026', role, html)
8
+ if quote:
9
+ html = re.sub(
10
+ r'(&ldquo;)(.*?)(&rdquo;)',
11
+ lambda m: f'&ldquo;{quote}&rdquo;',
12
+ html, flags=re.DOTALL
13
+ )
14
+ html = re.sub(r'Peserta &middot; AI Transformation Series', f'{name} &middot; AI Transformation Series', html)
15
+ return html
16
+
17
+ def main():
18
+ p = argparse.ArgumentParser()
19
+ p.add_argument("--input", required=True)
20
+ p.add_argument("--output", required=True)
21
+ p.add_argument("--name", default="Peserta Training")
22
+ p.add_argument("--role", default="AI Transformation Series")
23
+ p.add_argument("--quote", default="")
24
+ args = p.parse_args()
25
+ with open(args.input, encoding="utf-8") as f: html = f.read()
26
+ with open(args.output, "w", encoding="utf-8") as f: f.write(patch(html, args.name, args.role, args.quote))
27
+ print(f"Patched: {args.output}")
28
+
29
+ if __name__ == "__main__":
30
+ main()