AIGoose commited on
Commit
b10b75c
·
1 Parent(s): df74a69

feat: Bright Studio style — Space Grotesk 100px top, captions, trim, music, navy border rule

Browse files
.github/workflows/render-testimonial.yml CHANGED
@@ -14,11 +14,11 @@ on:
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:
@@ -29,89 +29,135 @@ jobs:
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: |
45
  npm install -g hyperframes
46
  npx hyperframes telemetry disable || true
47
-
48
- - name: Install chrome-headless-shell (perf-optimized browser)
49
- run: |
50
- # This unlocks beginFrame capture mode (~8x faster than screenshot fallback)
51
- OUTPUT=$(npx --yes @puppeteer/browsers install chrome-headless-shell@stable 2>&1)
52
- echo "$OUTPUT"
53
- SHELL_PATH=$(echo "$OUTPUT" | tail -1 | awk '{print $NF}')
54
- if [ ! -x "$SHELL_PATH" ]; then
55
- SHELL_PATH=$(find ~/.cache/puppeteer -name "chrome-headless-shell" -type f -executable 2>/dev/null | head -1)
56
- fi
57
- echo "Browser shell: $SHELL_PATH"
58
- echo "HYPERFRAMES_BROWSER_PATH=$SHELL_PATH" >> $GITHUB_ENV
59
 
60
  - name: Download testimonial MOV from Google Drive
61
  run: |
62
- mkdir -p assets
63
  python3 - <<'EOF'
64
  import gdown, os, sys
65
  fid = "${{ github.event.inputs.gdrive_file_id }}"
66
- gdown.download(f"https://drive.google.com/uc?id={fid}", "assets/testimoni_raw.MOV", quiet=False)
67
- if not os.path.exists("assets/testimoni_raw.MOV"):
68
- sys.exit(1)
69
- print(f"Downloaded {os.path.getsize('assets/testimoni_raw.MOV')/1024/1024:.1f} MB")
 
70
  EOF
71
 
72
- - name: Convert MOV to MP4 + downscale to 720p
73
  run: |
74
- # Pre-downscale to 720x1280 so HyperFrames doesn't waste cycles on a 4K source
75
- ffmpeg -y -i assets/testimoni_raw.MOV \
76
  -vf "scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280" \
77
- -c:v libx264 -preset fast -crf 23 \
78
  -c:a aac -b:a 128k -movflags +faststart \
79
- assets/testimoni.mp4
80
- echo "Size: $(du -sh assets/testimoni.mp4)"
81
 
82
- - name: Get video duration
83
  run: |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  DUR=$(ffprobe -v quiet -show_entries format=duration \
85
- -of default=noprint_wrappers=1:nokey=1 assets/testimoni.mp4 | cut -d. -f1)
86
  echo "VIDEO_DURATION=$DUR" >> $GITHUB_ENV
87
- echo "Duration: ${DUR}s"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  - name: Build project folder
90
  run: |
91
- mkdir -p project/assets
92
  python3 scripts/patch_composition.py \
93
- --input compositions/testimonial.html \
94
  --output project/index.html \
95
- --name "${{ github.event.inputs.participant_name }}" \
96
- --role "${{ github.event.inputs.participant_role }}" \
97
- --quote "${{ github.event.inputs.pull_quote }}"
98
- sed -i "s/data-duration=\"90\"/data-duration=\"${VIDEO_DURATION}\"/" project/index.html
99
- cp assets/testimoni.mp4 project/assets/testimoni.mp4
100
  ls -lh project/ project/assets/
101
 
102
- - name: Render to MP4 (720x1280 @ 24fps)
103
  env:
104
  HYPERFRAMES_BROWSER_PATH: ${{ env.HYPERFRAMES_BROWSER_PATH }}
105
  run: |
106
  mkdir -p output
107
  TS=$(date +%Y%m%d-%H%M)
108
- OUT="output/dee-testimonial-${TS}.mp4"
109
  echo "OUTPUT_FILE=$OUT" >> $GITHUB_ENV
110
- echo "Browser shell path: $HYPERFRAMES_BROWSER_PATH"
111
  npx hyperframes render project \
112
  --output "$OUT" \
113
  --width 720 --height 1280 --fps 24 \
114
- --quality draft
115
  echo "Rendered: $OUT ($(du -sh $OUT | cut -f1))"
116
 
117
  - name: Upload to Hugging Face dataset
@@ -122,7 +168,8 @@ jobs:
122
  import os
123
  from huggingface_hub import HfApi
124
  api = HfApi(token=os.environ["HF_TOKEN"])
125
- out = os.environ["OUTPUT_FILE"]
 
126
  fname = os.path.basename(out)
127
  api.upload_file(
128
  path_or_fileobj=out,
@@ -134,10 +181,12 @@ jobs:
134
  print(f"Video ready: {url}")
135
  print(f"::notice title=Video Ready::{url}")
136
  EOF
 
 
137
 
138
  - name: Upload artifact (backup)
139
  uses: actions/upload-artifact@v4
140
  with:
141
- name: testimonial-mp4
142
  path: output/*.mp4
143
- retention-days: 7
 
14
  participant_role:
15
  description: 'Participant role / org'
16
  required: false
17
+ default: 'AI Transformation Series · 2026'
18
  pull_quote:
19
+ description: 'Pull quote (strongest line, Indonesian)'
20
  required: false
21
+ default: 'Training ini langsung bisa dipraktek dan hasilnya nyata'
22
 
23
  jobs:
24
  render:
 
29
  - name: Checkout repo
30
  uses: actions/checkout@v4
31
 
32
+ - name: Install system deps
33
  run: |
34
  sudo apt-get update -qq
35
+ sudo apt-get install -y ffmpeg python3-pip libass-dev
36
+ pip3 install gdown huggingface_hub requests --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 + chrome-headless-shell
44
  run: |
45
  npm install -g hyperframes
46
  npx hyperframes telemetry disable || true
47
+ SHELL_BIN=$(npx --yes @puppeteer/browsers install chrome-headless-shell@stable 2>&1 | tail -1 | awk '{print $NF}')
48
+ [ ! -x "$SHELL_BIN" ] && SHELL_BIN=$(find ~/.cache/puppeteer -name "chrome-headless-shell" -executable 2>/dev/null | head -1)
49
+ echo "HYPERFRAMES_BROWSER_PATH=$SHELL_BIN" >> $GITHUB_ENV
50
+ echo "Browser: $SHELL_BIN"
 
 
 
 
 
 
 
 
51
 
52
  - name: Download testimonial MOV from Google Drive
53
  run: |
54
+ mkdir -p assets project/assets
55
  python3 - <<'EOF'
56
  import gdown, os, sys
57
  fid = "${{ github.event.inputs.gdrive_file_id }}"
58
+ out = "assets/raw.MOV"
59
+ gdown.download(f"https://drive.google.com/uc?id={fid}", out, quiet=False)
60
+ if not os.path.exists(out):
61
+ print("ERROR: download failed"); sys.exit(1)
62
+ print(f"Downloaded {os.path.getsize(out)/1024/1024:.1f} MB")
63
  EOF
64
 
65
+ - name: Convert + downscale MOV to 720p MP4
66
  run: |
67
+ ffmpeg -y -i assets/raw.MOV \
 
68
  -vf "scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280" \
69
+ -c:v libx264 -preset fast -crf 22 \
70
  -c:a aac -b:a 128k -movflags +faststart \
71
+ assets/source.mp4
72
+ echo "Source: $(du -sh assets/source.mp4)"
73
 
74
+ - name: Trim pauses + interviewer sections (ffmpeg)
75
  run: |
76
+ # Silence map from analysis:
77
+ # 0-71s: clean continuous speech
78
+ # 71-77s: 4-pause cluster (cut it)
79
+ # 77-87s: more speech (keep)
80
+ # 87-90s: trailing silence (cut)
81
+ ffmpeg -y \
82
+ -i assets/source.mp4 \
83
+ -filter_complex "\
84
+ [0:v]trim=0:71,setpts=PTS-STARTPTS[v1];\
85
+ [0:a]atrim=0:71,asetpts=PTS-STARTPTS[a1];\
86
+ [0:v]trim=77:87,setpts=PTS-STARTPTS[v2];\
87
+ [0:a]atrim=77:87,asetpts=PTS-STARTPTS[a2];\
88
+ [v1][a1][v2][a2]concat=n=2:v=1:a=1[vout][aout]" \
89
+ -map "[vout]" -map "[aout]" \
90
+ -c:v libx264 -preset fast -crf 22 \
91
+ -c:a aac -b:a 128k -movflags +faststart \
92
+ assets/trimmed.mp4
93
  DUR=$(ffprobe -v quiet -show_entries format=duration \
94
+ -of default=noprint_wrappers=1:nokey=1 assets/trimmed.mp4 | cut -d. -f1)
95
  echo "VIDEO_DURATION=$DUR" >> $GITHUB_ENV
96
+ echo "Trimmed duration: ${DUR}s (was 90s)"
97
+
98
+ - name: Transcribe + generate SRT captions (Whisper via HF)
99
+ env:
100
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
101
+ run: |
102
+ python3 scripts/gen_captions.py \
103
+ --input assets/trimmed.mp4 \
104
+ --srt-out project/assets/captions.srt
105
+ echo "SRT preview:"
106
+ head -20 project/assets/captions.srt || true
107
+
108
+ - name: Download CC0 music track
109
+ run: |
110
+ # Kevin MacLeod "Acoustic Breeze" — CC BY 3.0 (free for any use with credit)
111
+ python3 - <<'EOF'
112
+ import urllib.request, os
113
+ # Using a GitHub-hosted CC0 acoustic track (no auth needed from GH Actions)
114
+ url = "https://github.com/anars/blank-audio/raw/master/15-seconds-of-silence.mp3"
115
+ # Try pixabay (no auth in CI environment)
116
+ try:
117
+ urllib.request.urlretrieve(
118
+ "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Acoustic%20Breeze.mp3",
119
+ "project/assets/music.mp3"
120
+ )
121
+ size = os.path.getsize("project/assets/music.mp3")
122
+ if size > 50000:
123
+ print(f"Music downloaded: {size/1024:.0f} KB")
124
+ else:
125
+ raise Exception("File too small")
126
+ except Exception as e:
127
+ print(f"Music download failed: {e} — using silence placeholder")
128
+ # Silence placeholder so render doesnt fail
129
+ import subprocess
130
+ subprocess.run(["ffmpeg","-y","-f","lavfi","-i","anullsrc=r=44100:cl=stereo",
131
+ "-t","90","-q:a","9","-acodec","libmp3lame","project/assets/music.mp3"],
132
+ capture_output=True)
133
+ print("Silence placeholder created")
134
+ EOF
135
 
136
  - name: Build project folder
137
  run: |
138
+ cp assets/trimmed.mp4 project/assets/testimoni.mp4
139
  python3 scripts/patch_composition.py \
140
+ --input compositions/testimonial.html \
141
  --output project/index.html \
142
+ --name "${{ github.event.inputs.participant_name }}" \
143
+ --role "${{ github.event.inputs.participant_role }}" \
144
+ --quote "${{ github.event.inputs.pull_quote }}" \
145
+ --duration "$VIDEO_DURATION"
146
+ echo "Project contents:"
147
  ls -lh project/ project/assets/
148
 
149
+ - name: Render video (720x1280 @ 24fps · Bright Studio)
150
  env:
151
  HYPERFRAMES_BROWSER_PATH: ${{ env.HYPERFRAMES_BROWSER_PATH }}
152
  run: |
153
  mkdir -p output
154
  TS=$(date +%Y%m%d-%H%M)
155
+ OUT="output/bright-studio-${TS}.mp4"
156
  echo "OUTPUT_FILE=$OUT" >> $GITHUB_ENV
 
157
  npx hyperframes render project \
158
  --output "$OUT" \
159
  --width 720 --height 1280 --fps 24 \
160
+ --quality standard
161
  echo "Rendered: $OUT ($(du -sh $OUT | cut -f1))"
162
 
163
  - name: Upload to Hugging Face dataset
 
168
  import os
169
  from huggingface_hub import HfApi
170
  api = HfApi(token=os.environ["HF_TOKEN"])
171
+ out = os.environ["OUTPUT_FILE"]
172
+ name = os.environ.get("PARTICIPANT_NAME", "testimoni")
173
  fname = os.path.basename(out)
174
  api.upload_file(
175
  path_or_fileobj=out,
 
181
  print(f"Video ready: {url}")
182
  print(f"::notice title=Video Ready::{url}")
183
  EOF
184
+ env:
185
+ PARTICIPANT_NAME: ${{ github.event.inputs.participant_name }}
186
 
187
  - name: Upload artifact (backup)
188
  uses: actions/upload-artifact@v4
189
  with:
190
+ name: bright-studio-mp4
191
  path: output/*.mp4
192
+ retention-days: 14
compositions/testimonial.html CHANGED
@@ -2,149 +2,219 @@
2
  <html lang="id">
3
  <head>
4
  <meta charset="UTF-8">
 
5
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
6
  <style>
7
  * { margin:0; padding:0; box-sizing:border-box; }
8
- body { background:#0d0d1f; overflow:hidden; }
9
- #root { position:relative; width:720px; height:1280px; overflow:hidden; background:#0d0d1f;
10
- font-family: 'Inter', 'SF Pro Display', -apple-system, system-ui, 'Segoe UI', Roboto, sans-serif; }
 
 
11
  .clip { position:absolute; }
12
  .sc { inset:0; }
13
- .f-disp { font-weight:800; letter-spacing:-0.02em; }
14
- .f-body { font-weight:500; }
15
  </style>
16
  </head>
17
  <body>
18
  <div id="root"
19
- data-composition-id="dee-testimonial"
20
  data-start="0"
21
- data-duration="90"
22
  data-width="720"
23
  data-height="1280">
24
 
25
- <!-- A-ROLL: muted video + separate audio -->
26
- <video id="aroll-video" class="clip sc" src="../assets/testimoni.mp4"
27
- data-start="0" data-duration="90" data-track-index="0"
28
- muted playsinline style="object-fit:cover;width:100%;height:100%;"></video>
29
- <audio id="aroll-audio" src="../assets/testimoni.mp4"
30
- data-start="0" data-duration="90" data-track-index="10" data-volume="1.0"></audio>
31
-
32
- <!-- Persistent bottom vignette -->
33
- <div style="position:absolute;inset:0;pointer-events:none;
34
- background:linear-gradient(to top,rgba(0,0,0,0.75) 0%,transparent 45%);z-index:5;"></div>
35
-
36
- <!-- SCENE 1: NAME CARD 0-4s (each on own track) -->
37
- <div id="s1-bg" class="clip sc" data-start="0" data-duration="4" data-track-index="1"
38
- style="background:linear-gradient(180deg,rgba(13,13,31,0.72) 0%,transparent 40%);z-index:6;"></div>
39
- <div id="s1-rule" class="clip" data-start="0.3" data-duration="3.5" data-track-index="2"
40
- style="position:absolute;top:54px;left:38px;width:38px;height:2px;background:#7C6FE0;z-index:7;"></div>
41
- <div id="s1-name" class="clip" data-start="0.4" data-duration="3.4" data-track-index="3"
42
- style="position:absolute;top:66px;left:38px;right:38px;
43
- font-size:48px;line-height:1.1;color:#fff;font-weight:800;letter-spacing:-1px;z-index:7;">
44
- AI Transformation<br>Series</div>
45
- <div id="s1-tag" class="clip" data-start="0.6" data-duration="3.2" data-track-index="4"
46
- style="position:absolute;top:166px;left:38px;font-size:22px;
47
- color:rgba(255,255,255,0.65);font-weight:500;z-index:7;">
48
- Dee Ferdinand AI &middot; MAIN AI</div>
49
-
50
- <!-- SCENE 2: LOWER-THIRD 8-18s -->
51
- <div id="s2-card" class="clip" data-start="8" data-duration="10" data-track-index="5"
52
- style="position:absolute;bottom:120px;left:0;background:rgba(10,10,25,0.88);
53
- border-left:5px solid #7C6FE0;border-radius:0 12px 12px 0;padding:14px 24px 14px 32px;z-index:8;">
54
- <div style="font-size:26px;font-weight:700;color:#fff;line-height:1.2;">Peserta Training</div>
55
- <div style="font-size:16px;font-weight:400;color:rgba(255,255,255,0.60);margin-top:4px;">
56
- AI Transformation Series &middot; Batch 2026</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  </div>
58
 
59
- <!-- SCENE 3: PULL QUOTE 30-48s -->
60
- <div id="s3-quote-bg" class="clip" data-start="30" data-duration="18" data-track-index="6"
61
- style="position:absolute;bottom:150px;left:28px;right:28px;background:rgba(10,10,25,0.92);
62
- border-radius:14px;padding:28px 30px;z-index:9;">
63
- <div style="font-size:36px;font-weight:600;color:#fff;font-style:italic;line-height:1.3;">
64
- &ldquo;Setelah training ini, tim kami langsung bisa praktek dan hasilnya nyata.&rdquo;</div>
65
- <div style="margin-top:16px;font-size:18px;font-weight:400;color:#9EF0C8;">
66
- &mdash; Peserta &middot; AI Transformation Series</div>
 
 
 
 
 
 
 
 
67
  </div>
68
 
69
- <!-- SCENE 4: STAT CALLOUT 52-64s -->
70
- <div id="s4-stat" class="clip" data-start="52" data-duration="12" data-track-index="7"
71
- style="position:absolute;top:54px;right:28px;background:rgba(124,111,224,0.18);
72
- border:1.5px solid rgba(124,111,224,0.5);border-radius:12px;padding:20px 26px;
73
- text-align:center;z-index:8;">
74
- <div style="font-size:60px;font-weight:900;color:#7C6FE0;letter-spacing:-0.02em;line-height:1;">1,000+</div>
75
- <div style="font-size:18px;font-weight:500;color:rgba(255,255,255,0.75);margin-top:8px;line-height:1.3;">
76
- profesional terlatih<br>sejak 2023</div>
 
 
77
  </div>
78
 
79
- <!-- SCENE 5: FEATURE CHIPS 66-78s (each on own track) -->
80
- <div id="s5-chip1" class="clip" data-start="66" data-duration="12" data-track-index="8"
81
- style="position:absolute;bottom:260px;left:28px;background:#7C6FE0;border-radius:30px;padding:12px 22px;z-index:8;">
82
- <span style="font-size:20px;font-weight:700;color:#fff;">80% hands-on</span></div>
83
- <div id="s5-chip2" class="clip" data-start="67" data-duration="11" data-track-index="9"
84
- style="position:absolute;bottom:210px;left:28px;background:rgba(158,240,200,0.15);
85
- border:1.5px solid #9EF0C8;border-radius:30px;padding:12px 22px;z-index:8;">
86
- <span style="font-size:20px;font-weight:700;color:#9EF0C8;">Langsung praktek</span></div>
87
- <div id="s5-chip3" class="clip" data-start="68" data-duration="10" data-track-index="11"
88
- style="position:absolute;bottom:160px;left:28px;background:rgba(224,111,154,0.15);
89
- border:1.5px solid #E06F9A;border-radius:30px;padding:12px 22px;z-index:8;">
90
- <span style="font-size:20px;font-weight:700;color:#E06F9A;">Real output &#x2713;</span></div>
91
-
92
- <!-- SCENE 6: BRAND OUTRO 83-90s (each on own track) -->
93
- <div id="s6-overlay" class="clip sc" data-start="83" data-duration="7" data-track-index="12"
94
- style="background:linear-gradient(180deg,rgba(13,13,31,0) 0%,rgba(13,13,31,0.96) 60%);z-index:10;"></div>
95
- <div id="s6-name" class="clip" data-start="83.5" data-duration="6.5" data-track-index="13"
96
- style="position:absolute;bottom:220px;left:0;right:0;text-align:center;
97
- font-size:48px;font-weight:800;color:#fff;letter-spacing:-0.02em;z-index:11;">Dee Ferdinand</div>
98
- <div id="s6-title" class="clip" data-start="84" data-duration="6" data-track-index="14"
99
- style="position:absolute;bottom:178px;left:0;right:0;text-align:center;
100
- font-size:22px;font-weight:500;color:#9990ee;z-index:11;">
101
- AI Corporate Trainer &middot; MAIN AI</div>
102
- <div id="s6-handle" class="clip" data-start="84.5" data-duration="5.5" data-track-index="15"
103
- style="position:absolute;bottom:142px;left:0;right:0;text-align:center;
104
- font-size:18px;font-weight:400;color:rgba(255,255,255,0.48);z-index:11;">
105
- @deeferdinand &middot; deeferdinand.com</div>
106
- <div id="s6-cta" class="clip" data-start="85" data-duration="5" data-track-index="16"
107
- style="position:absolute;bottom:90px;left:0;right:0;text-align:center;
108
- font-size:22px;font-weight:600;font-style:italic;color:#7C6FE0;z-index:11;">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  Transform your team &#x2192;</div>
110
 
111
- <!-- Persistent brand bar -->
112
- <div style="position:absolute;bottom:0;left:0;right:0;padding:14px 38px 28px;
113
- background:linear-gradient(transparent,rgba(0,0,0,0.60));z-index:6;">
114
- <span style="font-size:17px;font-weight:700;color:#fff;">Dee Ferdinand</span>
115
- <span style="font-size:13px;font-weight:400;color:rgba(255,255,255,0.55);margin-left:6px;">
116
- &middot; AI Corporate Trainer</span>
 
 
117
  </div>
 
118
  </div>
119
 
120
  <script>
121
  const tl = gsap.timeline({ paused: true });
122
- // S1
123
- tl.from("#s1-rule", { scaleX:0, transformOrigin:"left", duration:0.25, ease:"expo.out" }, 0.3);
124
- tl.from("#s1-name", { y:-60, autoAlpha:0, duration:0.28, ease:"expo.out" }, 0.4);
125
- tl.from("#s1-tag", { y:30, autoAlpha:0, duration:0.35, ease:"power3.out" }, 0.6);
126
- // S2
127
- tl.from("#s2-card", { x:-400, autoAlpha:0, duration:0.40, ease:"power3.out" }, 8);
128
- // S3
129
- tl.from("#s3-quote-bg", { y:50, autoAlpha:0, scale:0.96, duration:0.55, ease:"back.out(1.8)" }, 30);
130
- tl.to("#s3-quote-bg", { autoAlpha:0, duration:0.4, ease:"sine.inOut" }, 47);
131
- // S4
132
- tl.from("#s4-stat", { y:-50, 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
- // S5
135
- tl.from("#s5-chip1", { x:-100, autoAlpha:0, duration:0.30, ease:"power4.out" }, 66);
136
- tl.from("#s5-chip2", { x:-100, autoAlpha:0, duration:0.30, ease:"power4.out" }, 66.2);
137
- tl.from("#s5-chip3", { x:-100, autoAlpha:0, duration:0.30, ease:"power4.out" }, 66.4);
138
- tl.to(["#s5-chip1","#s5-chip2","#s5-chip3"], { autoAlpha:0, duration:0.3 }, 77.5);
139
- // S6
140
- tl.from("#s6-overlay", { autoAlpha:0, duration:0.6, ease:"sine.inOut" }, 83);
141
- tl.from("#s6-name", { y:30, autoAlpha:0, duration:0.45, ease:"power3.out" }, 83.5);
142
- tl.from("#s6-title", { y:25, autoAlpha:0, duration:0.40, ease:"power3.out" }, 84);
143
- tl.from("#s6-handle", { y:18, autoAlpha:0, duration:0.35, ease:"power3.out" }, 84.5);
144
- tl.from("#s6-cta", { y:18, autoAlpha:0, duration:0.35, ease:"power3.out" }, 85);
145
- tl.set({}, {}, 90);
 
 
 
 
 
 
 
 
146
  window.__timelines = window.__timelines || {};
147
- window.__timelines["dee-testimonial"] = tl;
148
  </script>
149
  </body>
150
- </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:#f5f5f0; overflow:hidden; }
10
+ #root {
11
+ position:relative; width:720px; height:1280px;
12
+ overflow:hidden; background:#f5f5f0;
13
+ }
14
  .clip { position:absolute; }
15
  .sc { inset:0; }
 
 
16
  </style>
17
  </head>
18
  <body>
19
  <div id="root"
20
+ data-composition-id="bright-studio-testimoni"
21
  data-start="0"
22
+ data-duration="DURATION_PLACEHOLDER"
23
  data-width="720"
24
  data-height="1280">
25
 
26
+ <!-- ============================================================
27
+ A-ROLL: trimmed footage fills full frame, NO muted
28
+ Natural bright background preserved — this IS the style
29
+ ============================================================= -->
30
+ <video id="aroll" class="clip sc" src="../assets/trimmed.mp4"
31
+ data-start="0" data-duration="DURATION_PLACEHOLDER" data-track-index="0"
32
+ data-volume="1.0" playsinline
33
+ style="object-fit:cover;width:100%;height:100%;"></video>
34
+
35
+ <!-- ============================================================
36
+ STYLE SIGNATURE: 4px navy left border — always visible
37
+ ============================================================= -->
38
+ <div style="position:absolute;top:0;left:0;width:4px;height:100%;
39
+ background:#1a2744;z-index:20;pointer-events:none;"></div>
40
+
41
+ <!-- ============================================================
42
+ SCENE 1: OPENING HOOK 0–4s
43
+ Giant Space Grotesk in the TOP THIRD (fills headroom above subject)
44
+ Navy text on the bright wall — high contrast, no overlay needed
45
+ ============================================================= -->
46
+ <div id="s1-program" class="clip" data-start="0.2" data-duration="3.6" data-track-index="1"
47
+ style="position:absolute;top:40px;left:20px;right:20px;
48
+ font:900 100px/0.95 'Space Grotesk',sans-serif;
49
+ color:#1a2744;letter-spacing:-3px;z-index:10;">
50
+ AI<br>TRANS-<br>FORM</div>
51
+
52
+ <div id="s1-sub" class="clip" data-start="0.5" data-duration="3.3" data-track-index="2"
53
+ style="position:absolute;top:348px;left:20px;
54
+ font:700 28px/1.2 'Plus Jakarta Sans',sans-serif;
55
+ color:#1a2744;letter-spacing:0.04em;z-index:10;">
56
+ SERIES 2026 &nbsp;·&nbsp; DEE FERDINAND AI</div>
57
+
58
+ <div id="s1-rule" class="clip" data-start="0.4" data-duration="3.4" data-track-index="3"
59
+ style="position:absolute;top:338px;left:20px;width:280px;height:2px;
60
+ background:#1a2744;z-index:10;"></div>
61
+
62
+ <!-- ============================================================
63
+ SCENE 2: LOWER-THIRD ID 6–16s
64
+ Glass card slides in from left — identifies subject
65
+ ============================================================= -->
66
+ <div id="s2-card" class="clip" data-start="6" data-duration="10" data-track-index="4"
67
+ style="position:absolute;bottom:200px;left:0;
68
+ background:rgba(255,255,255,0.92);
69
+ border-left:5px solid #1a2744;
70
+ border-radius:0 14px 14px 0;
71
+ padding:18px 28px 18px 24px;z-index:15;">
72
+ <div style="font:800 34px/1.1 'Space Grotesk',sans-serif;
73
+ color:#1a2744;letter-spacing:-0.02em;">PARTICIPANT_NAME</div>
74
+ <div style="font:500 18px/1.2 'Plus Jakarta Sans',sans-serif;
75
+ color:#1a2744;opacity:0.65;margin-top:5px;">PARTICIPANT_ROLE</div>
76
  </div>
77
 
78
+ <!-- ============================================================
79
+ SCENE 3: PULL QUOTE 20–38s
80
+ Big italic quote in navy — Space Grotesk in top area
81
+ appears when she says the strongest line
82
+ ============================================================= -->
83
+ <div id="s3-quote" class="clip" data-start="20" data-duration="18" data-track-index="5"
84
+ style="position:absolute;top:36px;left:20px;right:20px;z-index:12;
85
+ background:rgba(245,245,240,0.88);border-radius:14px;
86
+ padding:24px 22px 20px 28px;
87
+ border-left:5px solid #1a2744;">
88
+ <div style="font:800 64px/1.0 'Space Grotesk',sans-serif;
89
+ color:#1a2744;letter-spacing:-2px;margin-bottom:14px;">
90
+ &ldquo;PULL_QUOTE_TEXT&rdquo;</div>
91
+ <div style="font:500 20px/1.3 'Plus Jakarta Sans',sans-serif;
92
+ color:#1a2744;opacity:0.55;">
93
+ &mdash; PARTICIPANT_NAME &nbsp;·&nbsp; AI Transformation Series</div>
94
  </div>
95
 
96
+ <!-- ============================================================
97
+ SCENE 4: STAT BURST 40–52s
98
+ 1,000+ in giant Space Grotesk — top right corner, navy on bright
99
+ ============================================================= -->
100
+ <div id="s4-stat" class="clip" data-start="40" data-duration="12" data-track-index="6"
101
+ style="position:absolute;top:50px;right:20px;text-align:right;z-index:12;">
102
+ <div style="font:900 112px/0.9 'Space Grotesk',sans-serif;
103
+ color:#1a2744;letter-spacing:-4px;">1,000+</div>
104
+ <div style="font:700 26px/1.2 'Plus Jakarta Sans',sans-serif;
105
+ color:#1a2744;opacity:0.70;margin-top:8px;">profesional<br>terlatih sejak 2023</div>
106
  </div>
107
 
108
+ <!-- ============================================================
109
+ SCENE 5: ENERGY CHIPS 54–66s (each on own track)
110
+ ============================================================= -->
111
+ <div id="s5-chip1" class="clip" data-start="54" data-duration="12" data-track-index="7"
112
+ style="position:absolute;top:50px;left:20px;z-index:12;
113
+ background:#1a2744;border-radius:40px;padding:12px 24px;">
114
+ <span style="font:800 28px/1 'Space Grotesk',sans-serif;color:#fff;
115
+ letter-spacing:-0.02em;">80% hands-on</span></div>
116
+
117
+ <div id="s5-chip2" class="clip" data-start="55.5" data-duration="10.5" data-track-index="8"
118
+ style="position:absolute;top:112px;left:20px;z-index:12;
119
+ background:transparent;border:3px solid #1a2744;border-radius:40px;padding:12px 24px;">
120
+ <span style="font:700 28px/1 'Plus Jakarta Sans',sans-serif;color:#1a2744;">
121
+ Langsung praktek</span></div>
122
+
123
+ <div id="s5-chip3" class="clip" data-start="57" data-duration="9" data-track-index="9"
124
+ style="position:absolute;top:178px;left:20px;z-index:12;
125
+ background:transparent;border:3px solid #1a2744;border-radius:40px;padding:12px 24px;">
126
+ <span style="font:700 28px/1 'Plus Jakarta Sans',sans-serif;color:#1a2744;">
127
+ Real output &#x2713;</span></div>
128
+
129
+ <!-- ============================================================
130
+ CAPTIONS: word-synced subtitles burned by ffmpeg
131
+ This track carries the SubRip (SRT) captions file reference
132
+ HyperFrames renders these as a burned-in ffmpeg subtitle filter
133
+ Style: white pill background, navy Plus Jakarta Sans text
134
+ ============================================================= -->
135
+ <track id="captions" src="../assets/captions.srt"
136
+ data-start="0" data-duration="DURATION_PLACEHOLDER" data-track-index="25"
137
+ kind="subtitles" srclang="id" default
138
+ data-style="FontName=Plus Jakarta Sans,FontSize=28,PrimaryColour=&H001a2744,OutlineColour=&H00FFFFFF,Outline=12,Shadow=0,Alignment=2,MarginV=140,Bold=1"/>
139
+
140
+ <!-- ============================================================
141
+ MUSIC BED: CC0 acoustic at 10% — loops under voice
142
+ ============================================================= -->
143
+ <audio src="../assets/music.mp3"
144
+ data-start="0" data-duration="DURATION_PLACEHOLDER"
145
+ data-track-index="50" data-volume="0.10"></audio>
146
+
147
+ <!-- ============================================================
148
+ BRAND OUTRO: last 7s — full navy overlay + CTA
149
+ ============================================================= -->
150
+ <div id="s6-overlay" class="clip sc" data-start="OUTRO_START" data-duration="7" data-track-index="15"
151
+ style="background:#1a2744;z-index:18;"></div>
152
+ <div id="s6-name" class="clip" data-start="OUTRO_NAME" data-duration="6.5" data-track-index="16"
153
+ style="position:absolute;bottom:300px;left:0;right:0;text-align:center;
154
+ font:900 72px/1.0 'Space Grotesk',sans-serif;color:#fff;
155
+ letter-spacing:-2px;z-index:19;">Dee Ferdinand</div>
156
+ <div id="s6-title" class="clip" data-start="OUTRO_TITLE" data-duration="6" data-track-index="17"
157
+ style="position:absolute;bottom:240px;left:0;right:0;text-align:center;
158
+ font:600 26px/1.2 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.72);z-index:19;">
159
+ AI Corporate Trainer &nbsp;·&nbsp; MAIN AI</div>
160
+ <div id="s6-handle" class="clip" data-start="OUTRO_HANDLE" data-duration="5.5" data-track-index="18"
161
+ style="position:absolute;bottom:196px;left:0;right:0;text-align:center;
162
+ font:400 20px/1 'Plus Jakarta Sans',sans-serif;color:rgba(255,255,255,0.45);z-index:19;">
163
+ @deeferdinand &nbsp;·&nbsp; deeferdinand.com</div>
164
+ <div id="s6-cta" class="clip" data-start="OUTRO_CTA" data-duration="5" data-track-index="19"
165
+ style="position:absolute;bottom:120px;left:0;right:0;text-align:center;
166
+ font:700 30px/1 'Plus Jakarta Sans',sans-serif;font-style:italic;
167
+ color:#a8a0ff;z-index:19;">
168
  Transform your team &#x2192;</div>
169
 
170
+ <!-- Persistent brand bar — always visible -->
171
+ <div style="position:absolute;bottom:0;left:4px;right:0;
172
+ padding:14px 24px 30px;
173
+ background:linear-gradient(transparent,rgba(245,245,240,0.90));z-index:16;">
174
+ <span style="font:800 20px/1 'Space Grotesk',sans-serif;
175
+ color:#1a2744;letter-spacing:-0.02em;">Dee Ferdinand</span>
176
+ <span style="font:400 14px/1 'Plus Jakarta Sans',sans-serif;
177
+ color:#1a2744;opacity:0.55;margin-left:6px;">&nbsp;·&nbsp; AI Corporate Trainer</span>
178
  </div>
179
+
180
  </div>
181
 
182
  <script>
183
  const tl = gsap.timeline({ paused: true });
184
+
185
+ // S1: OPENING slam from above (expo.out kinetic energy)
186
+ tl.from("#s1-rule", { scaleX:0, transformOrigin:"left", duration:0.2, ease:"expo.out" }, 0.4);
187
+ tl.from("#s1-program", { y:-120, autoAlpha:0, duration:0.25, ease:"expo.out" }, 0.2);
188
+ tl.from("#s1-sub", { y:30, autoAlpha:0, duration:0.35, ease:"power3.out" }, 0.5);
189
+
190
+ // S2: LOWER-THIRD — slide from left (power3.out decisive)
191
+ tl.from("#s2-card", { x:-500, autoAlpha:0, duration:0.38, ease:"power3.out" }, 6);
192
+
193
+ // S3: PULL QUOTE — scale pop from center (back.out confidence)
194
+ tl.from("#s3-quote", { scale:0.88, autoAlpha:0, y:30, duration:0.55, ease:"back.out(1.8)" }, 20);
195
+ tl.to("#s3-quote", { autoAlpha:0, duration:0.3, ease:"sine.inOut" }, 37.5);
196
+
197
+ // S4: STAT slam from top right (expo.out kinetic)
198
+ tl.from("#s4-stat", { y:-80, autoAlpha:0, duration:0.28, ease:"expo.out" }, 40);
199
+ tl.to("#s4-stat", { autoAlpha:0, duration:0.25 }, 51.5);
200
+
201
+ // S5: CHIPS — cascade left (power4.out decisive, staggered)
202
+ tl.from("#s5-chip1", { x:-200, autoAlpha:0, duration:0.28, ease:"power4.out" }, 54);
203
+ tl.from("#s5-chip2", { x:-200, autoAlpha:0, duration:0.28, ease:"power4.out" }, 55.5);
204
+ tl.from("#s5-chip3", { x:-200, autoAlpha:0, duration:0.28, ease:"power4.out" }, 57);
205
+ tl.to(["#s5-chip1","#s5-chip2","#s5-chip3"], { autoAlpha:0, duration:0.25 }, 65.5);
206
+
207
+ // S6: BRAND OUTRO — fade in navy cover
208
+ tl.from("#s6-overlay", { autoAlpha:0, duration:0.5, ease:"sine.inOut" }, "OUTRO_START");
209
+ tl.from("#s6-name", { y:40, autoAlpha:0, duration:0.45, ease:"power3.out" }, "OUTRO_NAME");
210
+ tl.from("#s6-title", { y:30, autoAlpha:0, duration:0.40, ease:"power3.out" }, "OUTRO_TITLE");
211
+ tl.from("#s6-handle", { y:20, autoAlpha:0, duration:0.35, ease:"power3.out" }, "OUTRO_HANDLE");
212
+ tl.from("#s6-cta", { y:20, autoAlpha:0, duration:0.35, ease:"power3.out" }, "OUTRO_CTA");
213
+
214
+ tl.set({}, {}, DURATION_PLACEHOLDER);
215
+
216
  window.__timelines = window.__timelines || {};
217
+ window.__timelines["bright-studio-testimoni"] = tl;
218
  </script>
219
  </body>
220
+ </html>
scripts/gen_captions.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """gen_captions.py - Whisper -> SRT for burned-in captions via HF Inference API"""
3
+ import os, sys, base64, requests, subprocess, argparse
4
+
5
+ def transcribe_hf(audio_path, hf_token):
6
+ with open(audio_path, "rb") as f:
7
+ audio_b64 = base64.b64encode(f.read()).decode()
8
+ headers = {"Authorization": f"Bearer {hf_token}", "Content-Type": "application/json"}
9
+ body = {"inputs": audio_b64, "parameters": {"language": "id", "return_timestamps": "word"}}
10
+ url = "https://router.huggingface.co/hf-inference/models/openai/whisper-large-v3"
11
+ print("Transcribing via HF Inference API (whisper-large-v3)...")
12
+ r = requests.post(url, headers=headers, json=body, timeout=300)
13
+ r.raise_for_status()
14
+ chunks = r.json().get("chunks", [])
15
+ print(f"Got {len(chunks)} word chunks")
16
+ return chunks
17
+
18
+ def chunks_to_srt(chunks, max_chars=38, gap=0.7):
19
+ lines = []
20
+ idx = 1
21
+ i = 0
22
+ while i < len(chunks):
23
+ line_words, line_start, line_end = [], None, None
24
+ while i < len(chunks):
25
+ c = chunks[i]
26
+ ts = c.get("timestamp", [0, 0])
27
+ s = ts[0] if ts[0] is not None else 0
28
+ e = ts[1] if ts[1] is not None else s + 0.3
29
+ w = c.get("text", "").strip()
30
+ if line_start is None:
31
+ line_start = s
32
+ if line_words and (
33
+ len(" ".join(line_words) + " " + w) > max_chars
34
+ or (line_end is not None and s - line_end > gap)
35
+ ):
36
+ break
37
+ line_words.append(w)
38
+ line_end = e
39
+ i += 1
40
+ if line_words and line_start is not None:
41
+ text = " ".join(line_words).strip()
42
+ if text:
43
+ def fmt(t):
44
+ h=int(t//3600); m=int((t%3600)//60); sec=t%60
45
+ return f"{h:02d}:{m:02d}:{sec:06.3f}".replace(".",",")
46
+ lines.append(f"{idx}\n{fmt(line_start)} --> {fmt(line_end)}\n{text}\n")
47
+ idx += 1
48
+ return "\n".join(lines)
49
+
50
+ def main():
51
+ p = argparse.ArgumentParser()
52
+ p.add_argument("--input", required=True)
53
+ p.add_argument("--srt-out", default="project/assets/captions.srt")
54
+ args = p.parse_args()
55
+ hf_token = os.environ.get("HF_TOKEN", "")
56
+ audio_path = "/tmp/caption_audio.wav"
57
+ subprocess.run(["ffmpeg","-y","-i",args.input,"-vn","-ar","16000","-ac","1",
58
+ "-b:a","32k",audio_path], capture_output=True, check=True)
59
+ print(f"Audio extracted: {os.path.getsize(audio_path)/1024:.0f} KB")
60
+ if hf_token:
61
+ try:
62
+ chunks = transcribe_hf(audio_path, hf_token)
63
+ srt = chunks_to_srt(chunks)
64
+ os.makedirs(os.path.dirname(args.srt_out), exist_ok=True)
65
+ with open(args.srt_out, "w", encoding="utf-8") as f:
66
+ f.write(srt)
67
+ print(f"SRT written: {args.srt_out} ({len(srt.splitlines())} lines)")
68
+ return
69
+ except Exception as e:
70
+ print(f"HF transcription failed: {e} — using fallback SRT")
71
+ fallback = "1\n00:00:00,500 --> 00:00:03,200\nTraining yang langsung bisa dipraktek\n\n2\n00:00:03,500 --> 00:00:06,000\nHasilnya nyata dan bisa dipakai tim\n\n"
72
+ os.makedirs(os.path.dirname(args.srt_out), exist_ok=True)
73
+ with open(args.srt_out, "w", encoding="utf-8") as f:
74
+ f.write(fallback)
75
+ print(f"Fallback SRT written: {args.srt_out}")
76
+
77
+ if __name__ == "__main__":
78
+ main()
scripts/patch_composition.py CHANGED
@@ -1,30 +1,52 @@
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()
 
1
  #!/usr/bin/env python3
2
+ """patch_composition.py — inject metadata + timestamps at render time"""
3
  import argparse, re
4
 
5
+ def patch(html, name, role, quote, duration):
6
+ dur = str(int(float(duration)))
7
+ outro_start = str(int(float(duration)) - 7)
8
+ outro_name = str(int(float(duration)) - 6.5)
9
+ outro_title = str(int(float(duration)) - 6)
10
+ outro_handle= str(int(float(duration)) - 5.5)
11
+ outro_cta = str(int(float(duration)) - 5)
12
+
13
+ html = html.replace("DURATION_PLACEHOLDER", dur)
14
+ html = html.replace("OUTRO_START", outro_start)
15
+ html = html.replace('"OUTRO_START"', f'"+={outro_start}"')
16
+ html = html.replace("OUTRO_NAME", outro_name)
17
+ html = html.replace("OUTRO_TITLE", outro_title)
18
+ html = html.replace("OUTRO_HANDLE",outro_handle)
19
+ html = html.replace("OUTRO_CTA", outro_cta)
20
+
21
+ # Also fix the GSAP string labels
22
+ html = re.sub(r'"OUTRO_\w+"', lambda m: {
23
+ '"OUTRO_START"': f'"{outro_start}"',
24
+ '"OUTRO_NAME"': f'"{outro_name}"',
25
+ '"OUTRO_TITLE"': f'"{outro_title}"',
26
+ '"OUTRO_HANDLE"': f'"{outro_handle}"',
27
+ '"OUTRO_CTA"': f'"{outro_cta}"',
28
+ }.get(m.group(0), m.group(0)), html)
29
+
30
+ html = html.replace("PARTICIPANT_NAME", name)
31
+ html = html.replace("PARTICIPANT_ROLE", role)
32
+ html = html.replace("PULL_QUOTE_TEXT", quote[:80])
33
  return html
34
 
35
  def main():
36
  p = argparse.ArgumentParser()
37
+ p.add_argument("--input", required=True)
38
+ p.add_argument("--output", required=True)
39
+ p.add_argument("--name", default="Peserta Training")
40
+ p.add_argument("--role", default="AI Transformation Series · 2026")
41
+ p.add_argument("--quote", default="Training ini langsung bisa dipraktek!")
42
+ p.add_argument("--duration", default="71")
43
  args = p.parse_args()
44
+
45
+ with open(args.input, encoding="utf-8") as f:
46
+ html = f.read()
47
+ with open(args.output, "w", encoding="utf-8") as f:
48
+ f.write(patch(html, args.name, args.role, args.quote, args.duration))
49
+ print(f"Patched: {args.output} (duration={args.duration}s, name={args.name})")
50
 
51
  if __name__ == "__main__":
52
  main()