Spaces:
Configuration error
Configuration error
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 +96 -47
- compositions/testimonial.html +187 -117
- scripts/gen_captions.py +78 -0
- scripts/patch_composition.py +41 -19
.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
|
| 18 |
pull_quote:
|
| 19 |
-
description: 'Pull quote
|
| 20 |
required: false
|
| 21 |
-
default: '
|
| 22 |
|
| 23 |
jobs:
|
| 24 |
render:
|
|
@@ -29,89 +29,135 @@ jobs:
|
|
| 29 |
- name: Checkout repo
|
| 30 |
uses: actions/checkout@v4
|
| 31 |
|
| 32 |
-
- name: Install system
|
| 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
|
| 44 |
run: |
|
| 45 |
npm install -g hyperframes
|
| 46 |
npx hyperframes telemetry disable || true
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
| 70 |
EOF
|
| 71 |
|
| 72 |
-
- name: Convert
|
| 73 |
run: |
|
| 74 |
-
|
| 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
|
| 78 |
-c:a aac -b:a 128k -movflags +faststart \
|
| 79 |
-
assets/
|
| 80 |
-
echo "
|
| 81 |
|
| 82 |
-
- name:
|
| 83 |
run: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
DUR=$(ffprobe -v quiet -show_entries format=duration \
|
| 85 |
-
-of default=noprint_wrappers=1:nokey=1 assets/
|
| 86 |
echo "VIDEO_DURATION=$DUR" >> $GITHUB_ENV
|
| 87 |
-
echo "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
- name: Build project folder
|
| 90 |
run: |
|
| 91 |
-
|
| 92 |
python3 scripts/patch_composition.py \
|
| 93 |
-
--input
|
| 94 |
--output project/index.html \
|
| 95 |
-
--name
|
| 96 |
-
--role
|
| 97 |
-
--quote
|
| 98 |
-
|
| 99 |
-
|
| 100 |
ls -lh project/ project/assets/
|
| 101 |
|
| 102 |
-
- name: Render
|
| 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/
|
| 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
|
| 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
|
|
|
|
| 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:
|
| 142 |
path: output/*.mp4
|
| 143 |
-
retention-days:
|
|
|
|
| 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:#
|
| 9 |
-
#root {
|
| 10 |
-
|
|
|
|
|
|
|
| 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="
|
| 20 |
data-start="0"
|
| 21 |
-
data-duration="
|
| 22 |
data-width="720"
|
| 23 |
data-height="1280">
|
| 24 |
|
| 25 |
-
<!--
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
<
|
| 30 |
-
data-start="0" data-duration="
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
<div
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
<div id="s1-
|
| 46 |
-
style="position:absolute;top:
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
<div id="
|
| 52 |
-
style="position:absolute;
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
</div>
|
| 58 |
|
| 59 |
-
<!--
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
</div>
|
| 68 |
|
| 69 |
-
<!--
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
<div style="font
|
| 76 |
-
|
|
|
|
|
|
|
| 77 |
</div>
|
| 78 |
|
| 79 |
-
<!--
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
font
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
<
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
Transform your team →</div>
|
| 110 |
|
| 111 |
-
<!-- Persistent brand bar -->
|
| 112 |
-
<div style="position:absolute;bottom:0;left:
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
<span style="font
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
</div>
|
|
|
|
| 118 |
</div>
|
| 119 |
|
| 120 |
<script>
|
| 121 |
const tl = gsap.timeline({ paused: true });
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
tl.from("#s1-
|
| 125 |
-
tl.from("#s1-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
//
|
| 129 |
-
tl.from("#
|
| 130 |
-
|
| 131 |
-
//
|
| 132 |
-
tl.from("#
|
| 133 |
-
tl.to("#
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
tl.from("#
|
| 137 |
-
tl.
|
| 138 |
-
|
| 139 |
-
//
|
| 140 |
-
tl.from("#
|
| 141 |
-
tl.from("#
|
| 142 |
-
tl.from("#
|
| 143 |
-
tl.
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
window.__timelines = window.__timelines || {};
|
| 147 |
-
window.__timelines["
|
| 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 · 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 |
+
“PULL_QUOTE_TEXT”</div>
|
| 91 |
+
<div style="font:500 20px/1.3 'Plus Jakarta Sans',sans-serif;
|
| 92 |
+
color:#1a2744;opacity:0.55;">
|
| 93 |
+
— PARTICIPANT_NAME · 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 ✓</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 · 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 · 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 →</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;"> · 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
|
| 3 |
import argparse, re
|
| 4 |
|
| 5 |
-
def patch(html, name, role, quote):
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
html =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
return html
|
| 16 |
|
| 17 |
def main():
|
| 18 |
p = argparse.ArgumentParser()
|
| 19 |
-
p.add_argument("--input",
|
| 20 |
-
p.add_argument("--output",
|
| 21 |
-
p.add_argument("--name",
|
| 22 |
-
p.add_argument("--role",
|
| 23 |
-
p.add_argument("--quote",
|
|
|
|
| 24 |
args = p.parse_args()
|
| 25 |
-
|
| 26 |
-
with open(args.
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 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()
|