AIGoose commited on
Commit
e7002b8
·
1 Parent(s): efd8a15

fix(v3): burn captions in ffmpeg, denoise voice, jump-cut pauses, title card before footage, fix headline overlap

Browse files
.github/workflows/render-testimonial.yml CHANGED
@@ -8,7 +8,7 @@ on:
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:
@@ -16,7 +16,7 @@ on:
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
 
@@ -32,8 +32,9 @@ jobs:
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
@@ -47,7 +48,7 @@ jobs:
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: |
@@ -62,7 +63,7 @@ jobs:
62
  print(f"Downloaded {os.path.getsize(out)/1024/1024:.1f} MB")
63
  PYEOF
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" \
@@ -71,10 +72,9 @@ jobs:
71
  assets/source.mp4
72
  echo "Source: $(du -sh assets/source.mp4)"
73
 
74
- - name: Trim pauses and interviewer sections
75
  run: |
76
- ffmpeg -y \
77
- -i assets/source.mp4 \
78
  -filter_complex "\
79
  [0:v]trim=0:71,setpts=PTS-STARTPTS[v1];\
80
  [0:a]atrim=0:71,asetpts=PTS-STARTPTS[a1];\
@@ -84,57 +84,102 @@ jobs:
84
  -map "[vout]" -map "[aout]" \
85
  -c:v libx264 -preset fast -crf 22 \
86
  -c:a aac -b:a 128k -movflags +faststart \
87
- assets/trimmed.mp4
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  DUR=$(ffprobe -v quiet -show_entries format=duration \
89
- -of default=noprint_wrappers=1:nokey=1 assets/trimmed.mp4 | cut -d. -f1)
90
- echo "VIDEO_DURATION=$DUR" >> $GITHUB_ENV
91
- echo "Trimmed: ${DUR}s (was 90s)"
92
 
93
- - name: Transcribe and generate SRT captions
94
  env:
95
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
96
  run: |
97
  mkdir -p project/assets
98
  python3 scripts/gen_captions.py \
99
- --input assets/trimmed.mp4 \
100
- --srt-out project/assets/captions.srt
101
- head -20 project/assets/captions.srt || true
102
 
103
- - name: Download CC0 music track
104
  run: |
105
- python3 - <<'PYEOF'
106
- import urllib.request, os, subprocess
107
- try:
108
- urllib.request.urlretrieve(
109
- "https://incompetech.com/music/royalty-free/mp3-royaltyfree/Acoustic%20Breeze.mp3",
110
- "project/assets/music.mp3"
111
- )
112
- size = os.path.getsize("project/assets/music.mp3")
113
- if size > 50000:
114
- print(f"Music downloaded: {size/1024:.0f} KB")
115
- else:
116
- raise Exception("File too small")
117
- except Exception as e:
118
- print(f"Music download failed: {e} — generating silence placeholder")
119
- subprocess.run(["ffmpeg","-y","-f","lavfi","-i","anullsrc=r=44100:cl=stereo",
120
- "-t","90","-q:a","9","-acodec","libmp3lame",
121
- "project/assets/music.mp3"], capture_output=True)
122
- print("Silence placeholder created")
123
- PYEOF
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  - name: Build project folder
126
  run: |
127
- cp assets/trimmed.mp4 project/assets/testimoni.mp4
 
 
128
  python3 scripts/patch_composition.py \
129
- --input compositions/testimonial.html \
130
- --output project/index.html \
131
- --name "${{ github.event.inputs.participant_name }}" \
132
- --role "${{ github.event.inputs.participant_role }}" \
133
- --quote "${{ github.event.inputs.pull_quote }}" \
134
- --duration "$VIDEO_DURATION"
135
- ls -lh project/ project/assets/
136
-
137
- - name: Render video (720x1280 @ 24fps)
 
 
 
 
138
  env:
139
  HYPERFRAMES_BROWSER_PATH: ${{ env.HYPERFRAMES_BROWSER_PATH }}
140
  run: |
@@ -143,12 +188,10 @@ jobs:
143
  OUT="output/bright-studio-${TS}.mp4"
144
  echo "OUTPUT_FILE=$OUT" >> $GITHUB_ENV
145
  npx hyperframes render project \
146
- --output "$OUT" \
147
- --width 720 --height 1280 --fps 24 \
148
- --quality standard
149
  echo "Rendered: $OUT ($(du -sh $OUT | cut -f1))"
150
 
151
- - name: Upload to Hugging Face dataset
152
  env:
153
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
154
  PARTICIPANT_NAME: ${{ github.event.inputs.participant_name }}
@@ -156,15 +199,12 @@ jobs:
156
  python3 - <<'PYEOF'
157
  import os
158
  from huggingface_hub import HfApi
159
- api = HfApi(token=os.environ["HF_TOKEN"])
160
  out = os.environ["OUTPUT_FILE"]
161
  fname = os.path.basename(out)
162
- api.upload_file(
163
- path_or_fileobj=out,
164
  path_in_repo=f"renders/{fname}",
165
- repo_id="AIgoose/video-renders",
166
- repo_type="dataset",
167
- )
168
  url = f"https://huggingface.co/datasets/AIgoose/video-renders/resolve/main/renders/{fname}"
169
  print(f"Video ready: {url}")
170
  print(f"::notice title=Video Ready::{url}")
@@ -173,6 +213,6 @@ jobs:
173
  - name: Upload artifact (backup)
174
  uses: actions/upload-artifact@v4
175
  with:
176
- name: bright-studio-mp4
177
  path: output/*.mp4
178
  retention-days: 14
 
8
  required: true
9
  default: '1f1qlgyLJSCQgpjrWflaqldsNE2fZ-_1X'
10
  participant_name:
11
+ description: 'Participant name'
12
  required: false
13
  default: 'Peserta Training'
14
  participant_role:
 
16
  required: false
17
  default: 'AI Transformation Series 2026'
18
  pull_quote:
19
+ description: 'Pull quote (Indonesian)'
20
  required: false
21
  default: 'Training ini langsung bisa dipraktek dan hasilnya nyata'
22
 
 
32
  - name: Install system deps
33
  run: |
34
  sudo apt-get update -qq
35
+ sudo apt-get install -y ffmpeg python3-pip libass-dev fonts-liberation
36
  pip3 install gdown huggingface_hub requests --quiet
37
+ fc-cache -fv 2>/dev/null || true
38
 
39
  - name: Setup Node.js 22
40
  uses: actions/setup-node@v4
 
48
  SHELL_BIN=$(npx --yes @puppeteer/browsers install chrome-headless-shell@stable 2>&1 | tail -1 | awk '{print $NF}')
49
  [ ! -x "$SHELL_BIN" ] && SHELL_BIN=$(find ~/.cache/puppeteer -name "chrome-headless-shell" -executable 2>/dev/null | head -1)
50
  echo "HYPERFRAMES_BROWSER_PATH=$SHELL_BIN" >> $GITHUB_ENV
51
+ echo "Browser shell: $SHELL_BIN"
52
 
53
  - name: Download testimonial MOV from Google Drive
54
  run: |
 
63
  print(f"Downloaded {os.path.getsize(out)/1024/1024:.1f} MB")
64
  PYEOF
65
 
66
+ - name: Convert MOV to 720p source MP4
67
  run: |
68
  ffmpeg -y -i assets/raw.MOV \
69
  -vf "scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280" \
 
72
  assets/source.mp4
73
  echo "Source: $(du -sh assets/source.mp4)"
74
 
75
+ - name: Cut interviewer section (71-77s cluster)
76
  run: |
77
+ ffmpeg -y -i assets/source.mp4 \
 
78
  -filter_complex "\
79
  [0:v]trim=0:71,setpts=PTS-STARTPTS[v1];\
80
  [0:a]atrim=0:71,asetpts=PTS-STARTPTS[a1];\
 
84
  -map "[vout]" -map "[aout]" \
85
  -c:v libx264 -preset fast -crf 22 \
86
  -c:a aac -b:a 128k -movflags +faststart \
87
+ assets/cut.mp4
88
+ echo "After cut: $(ffprobe -v quiet -show_entries format=duration \
89
+ -of default=noprint_wrappers=1:nokey=1 assets/cut.mp4 | cut -d. -f1)s"
90
+
91
+ - name: Denoise voice + jump-cut pauses
92
+ run: |
93
+ ffmpeg -y -i assets/cut.mp4 \
94
+ -filter_complex "\
95
+ [0:a]anlmdn=s=7:p=0.002:r=0.002:m=15[dn];\
96
+ [dn]silenceremove=start_periods=1:start_silence=0.3:start_threshold=-35dB\
97
+ :stop_periods=-1:stop_silence=0.05:stop_threshold=-35dB[clean]" \
98
+ -map "[0:v]" -map "[clean]" \
99
+ -c:v copy -c:a aac -b:a 128k \
100
+ assets/denoised.mp4
101
  DUR=$(ffprobe -v quiet -show_entries format=duration \
102
+ -of default=noprint_wrappers=1:nokey=1 assets/denoised.mp4 | xargs printf "%.1f")
103
+ echo "FOOTAGE_DUR=$DUR" >> $GITHUB_ENV
104
+ echo "After denoise+jumpcut: ${DUR}s"
105
 
106
+ - name: Transcribe + generate SRT captions
107
  env:
108
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
109
  run: |
110
  mkdir -p project/assets
111
  python3 scripts/gen_captions.py \
112
+ --input assets/denoised.mp4 \
113
+ --srt-out assets/captions.srt
114
+ echo "SRT preview:"; head -20 assets/captions.srt || true
115
 
116
+ - name: Burn captions into video (ffmpeg, before HyperFrames)
117
  run: |
118
+ # Install a system font for subtitle rendering
119
+ FONT_PATH=$(fc-list | grep -i liberation | grep -i regular | head -1 | cut -d: -f1 || echo "")
120
+ [ -z "$FONT_PATH" ] && FONT_PATH="/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
121
+ echo "Using font: $FONT_PATH"
122
+
123
+ ffmpeg -y -i assets/denoised.mp4 \
124
+ -vf "subtitles=assets/captions.srt:force_style='FontName=Liberation Sans,FontSize=22,PrimaryColour=&H001a2744,OutlineColour=&H00FFFFFF,Outline=10,Shadow=0,Alignment=2,MarginV=120,Bold=1'" \
125
+ -c:v libx264 -preset fast -crf 22 \
126
+ -c:a copy \
127
+ assets/captioned.mp4
128
+ echo "Captioned video: $(du -sh assets/captioned.mp4)"
129
+
130
+ - name: Mix CC0 background music
131
+ run: |
132
+ # Use music.mp3 from repo assets/ if present, else generate tone
133
+ if [ -f "assets/music.mp3" ]; then
134
+ echo "Using repo music.mp3"
135
+ MUSIC="assets/music.mp3"
136
+ else
137
+ echo "Generating lo-fi tone placeholder"
138
+ ffmpeg -y -f lavfi -i \
139
+ "aevalsrc=0.08*sin(2*PI*220*t)+0.05*sin(2*PI*440*t)+0.03*sin(2*PI*880*t):s=44100" \
140
+ -af "volume=0.12,lowpass=f=800,highpass=f=80" \
141
+ -t 120 -q:a 6 assets/music_gen.mp3 2>/dev/null
142
+ MUSIC="assets/music_gen.mp3"
143
+ fi
144
+
145
+ DUR=$(ffprobe -v quiet -show_entries format=duration \
146
+ -of default=noprint_wrappers=1:nokey=1 assets/captioned.mp4 | xargs printf "%.1f")
147
+
148
+ ffmpeg -y \
149
+ -i assets/captioned.mp4 \
150
+ -stream_loop -1 -i "$MUSIC" \
151
+ -filter_complex "\
152
+ [1:a]atrim=0:${DUR},asetpts=PTS-STARTPTS,volume=0.10[music];\
153
+ [0:a][music]amix=inputs=2:duration=first:dropout_transition=2[aout]" \
154
+ -map "0:v" -map "[aout]" \
155
+ -c:v copy -c:a aac -b:a 128k \
156
+ assets/clean.mp4
157
+ FOOTAGE_DUR=$(ffprobe -v quiet -show_entries format=duration \
158
+ -of default=noprint_wrappers=1:nokey=1 assets/clean.mp4 | xargs printf "%.0f")
159
+ TOTAL_DUR=$((FOOTAGE_DUR + 2))
160
+ echo "FOOTAGE_DUR=$FOOTAGE_DUR" >> $GITHUB_ENV
161
+ echo "TOTAL_DUR=$TOTAL_DUR" >> $GITHUB_ENV
162
+ echo "Final clean.mp4: ${FOOTAGE_DUR}s | total with title card: ${TOTAL_DUR}s"
163
 
164
  - name: Build project folder
165
  run: |
166
+ cp assets/clean.mp4 project/assets/clean.mp4
167
+ [ -f "assets/music.mp3" ] && cp assets/music.mp3 project/assets/music.mp3 || \
168
+ cp assets/music_gen.mp3 project/assets/music.mp3
169
  python3 scripts/patch_composition.py \
170
+ --input compositions/testimonial.html \
171
+ --output project/index.html \
172
+ --name "${{ github.event.inputs.participant_name }}" \
173
+ --role "${{ github.event.inputs.participant_role }}" \
174
+ --quote "${{ github.event.inputs.pull_quote }}" \
175
+ --footage-dur "$FOOTAGE_DUR" \
176
+ --total-dur "$TOTAL_DUR"
177
+ echo "Project contents:"; ls -lh project/ project/assets/
178
+
179
+ - name: Lint composition
180
+ run: npx hyperframes lint project || true
181
+
182
+ - name: Render (720x1280 @ 24fps - Bright Studio v3)
183
  env:
184
  HYPERFRAMES_BROWSER_PATH: ${{ env.HYPERFRAMES_BROWSER_PATH }}
185
  run: |
 
188
  OUT="output/bright-studio-${TS}.mp4"
189
  echo "OUTPUT_FILE=$OUT" >> $GITHUB_ENV
190
  npx hyperframes render project \
191
+ --output "$OUT" --width 720 --height 1280 --fps 24 --quality standard
 
 
192
  echo "Rendered: $OUT ($(du -sh $OUT | cut -f1))"
193
 
194
+ - name: Upload to Hugging Face
195
  env:
196
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
197
  PARTICIPANT_NAME: ${{ github.event.inputs.participant_name }}
 
199
  python3 - <<'PYEOF'
200
  import os
201
  from huggingface_hub import HfApi
202
+ api = HfApi(token=os.environ["HF_TOKEN"])
203
  out = os.environ["OUTPUT_FILE"]
204
  fname = os.path.basename(out)
205
+ api.upload_file(path_or_fileobj=out,
 
206
  path_in_repo=f"renders/{fname}",
207
+ repo_id="AIgoose/video-renders", repo_type="dataset")
 
 
208
  url = f"https://huggingface.co/datasets/AIgoose/video-renders/resolve/main/renders/{fname}"
209
  print(f"Video ready: {url}")
210
  print(f"::notice title=Video Ready::{url}")
 
213
  - name: Upload artifact (backup)
214
  uses: actions/upload-artifact@v4
215
  with:
216
+ name: bright-studio-v3
217
  path: output/*.mp4
218
  retention-days: 14
compositions/testimonial.html CHANGED
@@ -1,220 +1,163 @@
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:#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>
 
1
  <!DOCTYPE html>
2
+ <html lang="id"><head><meta charset="UTF-8">
 
 
3
  <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">
4
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
5
  <style>
6
  * { margin:0; padding:0; box-sizing:border-box; }
7
  body { background:#f5f5f0; overflow:hidden; }
8
+ #root { position:relative; width:720px; height:1280px; overflow:hidden; }
 
 
 
9
  .clip { position:absolute; }
10
  .sc { inset:0; }
11
+ </style></head><body>
 
 
12
  <div id="root"
13
+ data-composition-id="bright-studio-v3"
14
+ data-start="0"
15
+ data-duration="TOTAL_DUR"
16
+ data-width="720" data-height="1280">
 
 
 
 
 
 
 
 
 
 
17
 
18
+ <!-- ── STYLE RULE: 4px navy left border always visible ────── -->
 
 
19
  <div style="position:absolute;top:0;left:0;width:4px;height:100%;
20
+ background:#1a2744;z-index:50;pointer-events:none;"></div>
21
+
22
+ <!-- ── SCENE 0: TITLE CARD 0–2.5s (navy bg, NO footage) ──── -->
23
+ <div id="s0-bg" class="clip sc" data-start="0" data-duration="2.5" data-track-index="0"
24
+ style="background:#1a2744;z-index:5;"></div>
25
+ <div id="s0-line" class="clip" data-start="0.15" data-duration="2.2" data-track-index="1"
26
+ style="position:absolute;top:480px;left:20px;width:60px;height:3px;
27
+ background:#fff;z-index:6;"></div>
28
+ <div id="s0-head" class="clip" data-start="0.2" data-duration="2.1" data-track-index="2"
29
+ style="position:absolute;top:500px;left:20px;right:20px;
30
+ font:900 80px/0.95 'Space Grotesk',sans-serif;color:#fff;
31
+ letter-spacing:-2px;z-index:6;">AI<br>TRANS-<br>FORM</div>
32
+ <div id="s0-sub" class="clip" data-start="0.45" data-duration="1.85" data-track-index="3"
33
+ style="position:absolute;top:750px;left:20px;
34
+ font:600 22px/1.3 'Plus Jakarta Sans',sans-serif;
35
+ color:rgba(255,255,255,0.60);letter-spacing:0.05em;z-index:6;">
 
36
  SERIES 2026 &nbsp;·&nbsp; DEE FERDINAND AI</div>
37
 
38
+ <!-- ── SCENE 1: FOOTAGE 2.2–END (captions burned in by ffmpeg) -->
39
+ <video id="aroll" class="clip sc" src="../assets/clean.mp4"
40
+ data-start="2.2" data-duration="FOOTAGE_DUR" data-track-index="4"
41
+ data-volume="1.0" playsinline
42
+ style="object-fit:cover;width:100%;height:100%;"></video>
43
+
44
+ <!-- Bottom vignette over footage (persistent after t=2.2) -->
45
+ <div class="clip sc" data-start="2.2" data-duration="FOOTAGE_DUR" data-track-index="5"
46
+ style="background:linear-gradient(to top,rgba(0,0,0,0.55) 0%,transparent 35%);
47
+ pointer-events:none;z-index:6;"></div>
48
+
49
+ <!-- ── SCENE 2: LOWER-THIRD ID 5–13s ──────────────────────── -->
50
+ <div id="s2-lt" class="clip" data-start="5" data-duration="8" data-track-index="6"
51
+ style="position:absolute;bottom:180px;left:0;
52
+ background:rgba(255,255,255,0.94);
53
+ border-left:5px solid #1a2744;border-radius:0 12px 12px 0;
54
+ padding:16px 26px 16px 22px;z-index:10;">
55
+ <div style="font:800 32px/1.1 'Space Grotesk',sans-serif;
56
  color:#1a2744;letter-spacing:-0.02em;">PARTICIPANT_NAME</div>
57
+ <div style="font:500 17px/1.3 'Plus Jakarta Sans',sans-serif;
58
+ color:#1a2744;opacity:0.55;margin-top:4px;">PARTICIPANT_ROLE</div>
59
  </div>
60
 
61
+ <!-- ── SCENE 3: PULL QUOTE 18–26s (8s only) ──────────────── -->
62
+ <div id="s3-q" class="clip" data-start="18" data-duration="8" data-track-index="7"
63
+ style="position:absolute;top:36px;left:12px;right:12px;z-index:10;
64
+ background:rgba(26,39,68,0.90);border-radius:14px;
65
+ padding:22px 22px 18px 22px;">
66
+ <div style="font:800 52px/1.05 'Space Grotesk',sans-serif;
67
+ color:#fff;letter-spacing:-1.5px;margin-bottom:12px;">
 
 
 
 
 
68
  &ldquo;PULL_QUOTE_TEXT&rdquo;</div>
69
+ <div style="font:500 17px/1.3 'Plus Jakarta Sans',sans-serif;
70
+ color:rgba(255,255,255,0.55);">
71
  &mdash; PARTICIPANT_NAME &nbsp;·&nbsp; AI Transformation Series</div>
72
  </div>
73
 
74
+ <!-- ── SCENE 4: CHIPS 30–42s (each on own track) ─────────── -->
75
+ <div id="s4-c1" class="clip" data-start="30" data-duration="12" data-track-index="8"
76
+ style="position:absolute;bottom:280px;left:12px;
77
+ background:#1a2744;border-radius:40px;padding:12px 22px;z-index:10;">
78
+ <span style="font:800 26px/1 'Space Grotesk',sans-serif;color:#fff;">80% hands-on</span></div>
79
+ <div id="s4-c2" class="clip" data-start="31.5" data-duration="10.5" data-track-index="9"
80
+ style="position:absolute;bottom:228px;left:12px;border:3px solid #1a2744;
81
+ border-radius:40px;padding:12px 22px;z-index:10;">
82
+ <span style="font:700 26px/1 'Plus Jakarta Sans',sans-serif;color:#1a2744;">Langsung praktek</span></div>
83
+ <div id="s4-c3" class="clip" data-start="33" data-duration="9" data-track-index="10"
84
+ style="position:absolute;bottom:176px;left:12px;border:3px solid #1a2744;
85
+ border-radius:40px;padding:12px 22px;z-index:10;">
86
+ <span style="font:700 26px/1 'Plus Jakarta Sans',sans-serif;color:#1a2744;">Real output &#x2713;</span></div>
87
+
88
+ <!-- ── SCENE 5: STAT 44–54s ───────────────────────────────── -->
89
+ <div id="s5-stat" class="clip" data-start="44" data-duration="10" data-track-index="11"
90
+ style="position:absolute;top:36px;right:12px;text-align:right;z-index:10;">
91
+ <div style="font:900 100px/0.9 'Space Grotesk',sans-serif;
92
+ color:#1a2744;letter-spacing:-3px;">1,000+</div>
93
+ <div style="font:600 22px/1.3 'Plus Jakarta Sans',sans-serif;
94
+ color:#1a2744;opacity:0.65;margin-top:6px;">profesional terlatih<br>sejak 2023</div>
95
  </div>
96
 
97
+ <!-- ── SCENE 6: BRAND OUTRO ───────────────────────────────── -->
98
+ <div id="s6-ov" class="clip sc" data-start="OUTRO_START" data-duration="7" data-track-index="12"
99
+ style="background:#1a2744;z-index:20;"></div>
100
+ <div id="s6-nm" class="clip" data-start="OUTRO_S1" data-duration="6.5" data-track-index="13"
101
+ style="position:absolute;bottom:300px;left:0;right:0;text-align:center;
102
+ font:900 64px/1.0 'Space Grotesk',sans-serif;color:#fff;
103
+ letter-spacing:-2px;z-index:21;">Dee Ferdinand</div>
104
+ <div id="s6-ti" class="clip" data-start="OUTRO_S2" data-duration="6" data-track-index="14"
105
+ style="position:absolute;bottom:246px;left:0;right:0;text-align:center;
106
+ font:600 24px/1.2 'Plus Jakarta Sans',sans-serif;
107
+ color:rgba(255,255,255,0.65);z-index:21;">AI Corporate Trainer &nbsp;·&nbsp; MAIN AI</div>
108
+ <div id="s6-hd" class="clip" data-start="OUTRO_S3" data-duration="5.5" data-track-index="15"
109
+ style="position:absolute;bottom:206px;left:0;right:0;text-align:center;
110
+ font:400 18px/1 'Plus Jakarta Sans',sans-serif;
111
+ color:rgba(255,255,255,0.40);z-index:21;">@deeferdinand &nbsp;·&nbsp; deeferdinand.com</div>
112
+ <div id="s6-ct" class="clip" data-start="OUTRO_S4" data-duration="5" data-track-index="16"
113
+ style="position:absolute;bottom:140px;left:0;right:0;text-align:center;
114
+ font:700 26px/1 'Plus Jakarta Sans',sans-serif;font-style:italic;
115
+ color:#a8a0ff;z-index:21;">Transform your team &#x2192;</div>
116
+
117
+ <!-- Music (CC0 hosted in repo assets) -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  <audio src="../assets/music.mp3"
119
+ data-start="0" data-duration="TOTAL_DUR"
120
  data-track-index="50" data-volume="0.10"></audio>
121
 
122
+ <!-- Persistent brand bar -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  <div style="position:absolute;bottom:0;left:4px;right:0;
124
+ padding:12px 20px 28px;
125
+ background:linear-gradient(transparent,rgba(245,245,240,0.92));z-index:9;">
126
+ <span style="font:800 19px/1 'Space Grotesk',sans-serif;color:#1a2744;
127
+ letter-spacing:-0.02em;">Dee Ferdinand</span>
128
+ <span style="font:400 13px/1 'Plus Jakarta Sans',sans-serif;
129
+ color:#1a2744;opacity:0.50;margin-left:5px;">&nbsp;·&nbsp; AI Corporate Trainer</span>
130
  </div>
 
131
  </div>
132
 
133
  <script>
134
  const tl = gsap.timeline({ paused: true });
135
 
136
+ tl.from("#s0-line", { scaleX:0, transformOrigin:"left", duration:0.18, ease:"expo.out" }, 0.15);
137
+ tl.from("#s0-head", { y:-80, autoAlpha:0, duration:0.22, ease:"expo.out" }, 0.2);
138
+ tl.from("#s0-sub", { y:30, autoAlpha:0, duration:0.30, ease:"power3.out" }, 0.45);
 
 
 
 
139
 
140
+ tl.from("#s2-lt", { x:-480, autoAlpha:0, duration:0.38, ease:"power3.out" }, 5);
 
 
141
 
142
+ tl.from("#s3-q", { scale:0.90, autoAlpha:0, duration:0.45, ease:"back.out(1.6)" }, 18);
143
+ tl.to("#s3-q", { autoAlpha:0, duration:0.28, ease:"sine.inOut" }, 25.7);
 
144
 
145
+ tl.from("#s4-c1", { x:-200, autoAlpha:0, duration:0.26, ease:"power4.out" }, 30);
146
+ tl.from("#s4-c2", { x:-200, autoAlpha:0, duration:0.26, ease:"power4.out" }, 31.5);
147
+ tl.from("#s4-c3", { x:-200, autoAlpha:0, duration:0.26, ease:"power4.out" }, 33);
148
+ tl.to(["#s4-c1","#s4-c2","#s4-c3"], { autoAlpha:0, duration:0.22 }, 41.5);
 
149
 
150
+ tl.from("#s5-stat", { y:-60, autoAlpha:0, duration:0.28, ease:"expo.out" }, 44);
151
+ tl.to("#s5-stat", { autoAlpha:0, duration:0.22 }, 53.5);
 
 
 
 
152
 
153
+ tl.from("#s6-ov", { autoAlpha:0, duration:0.50, ease:"sine.inOut" }, "OUTRO_START");
154
+ tl.from("#s6-nm", { y:36, autoAlpha:0, duration:0.42, ease:"power3.out" }, "OUTRO_S1");
155
+ tl.from("#s6-ti", { y:28, autoAlpha:0, duration:0.38, ease:"power3.out" }, "OUTRO_S2");
156
+ tl.from("#s6-hd", { y:20, autoAlpha:0, duration:0.33, ease:"power3.out" }, "OUTRO_S3");
157
+ tl.from("#s6-ct", { y:20, autoAlpha:0, duration:0.33, ease:"power3.out" }, "OUTRO_S4");
158
 
159
+ tl.set({}, {}, TOTAL_DUR);
160
  window.__timelines = window.__timelines || {};
161
+ window.__timelines["bright-studio-v3"] = tl;
162
  </script>
163
+ </body></html>
 
scripts/patch_composition.py CHANGED
@@ -1,52 +1,47 @@
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()
 
1
  #!/usr/bin/env python3
2
+ """patch_composition.py v2 - inject name/role/quote/durations"""
3
  import argparse, re
4
 
5
+ def patch(html, name, role, quote, footage_dur, total_dur):
6
+ fd = int(float(footage_dur))
7
+ td = int(float(total_dur))
8
+ os = str(td - 7) # outro start
9
+ s1 = str(td - 6.5)
10
+ s2 = str(td - 6)
11
+ s3 = str(td - 5.5)
12
+ s4 = str(td - 5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ html = html.replace("TOTAL_DUR", str(td))
15
+ html = html.replace("FOOTAGE_DUR", str(fd))
16
+ html = html.replace("OUTRO_START", os)
17
+ html = html.replace("OUTRO_S1", s1)
18
+ html = html.replace("OUTRO_S2", s2)
19
+ html = html.replace("OUTRO_S3", s3)
20
+ html = html.replace("OUTRO_S4", s4)
21
+ html = re.sub(r'"OUTRO_START"', f'"{os}"', html)
22
+ html = re.sub(r'"OUTRO_S1"', f'"{s1}"', html)
23
+ html = re.sub(r'"OUTRO_S2"', f'"{s2}"', html)
24
+ html = re.sub(r'"OUTRO_S3"', f'"{s3}"', html)
25
+ html = re.sub(r'"OUTRO_S4"', f'"{s4}"', html)
26
  html = html.replace("PARTICIPANT_NAME", name)
27
  html = html.replace("PARTICIPANT_ROLE", role)
28
+ q80 = (quote[:78] + "...") if len(quote) > 80 else quote
29
+ html = html.replace("PULL_QUOTE_TEXT", q80)
30
  return html
31
 
32
  def main():
33
  p = argparse.ArgumentParser()
34
+ p.add_argument("--input", required=True)
35
+ p.add_argument("--output", required=True)
36
+ p.add_argument("--name", default="Peserta Training")
37
+ p.add_argument("--role", default="AI Transformation Series 2026")
38
+ p.add_argument("--quote", default="Training ini langsung bisa dipraktek dan hasilnya nyata")
39
+ p.add_argument("--footage-dur", default="75")
40
+ p.add_argument("--total-dur", default="77.5")
41
  args = p.parse_args()
42
+ with open(args.input, encoding="utf-8") as f: html = f.read()
43
+ out = patch(html, args.name, args.role, args.quote, args.footage_dur, args.total_dur)
44
+ with open(args.output, "w", encoding="utf-8") as f: f.write(out)
45
+ print(f"Patched OK — footage={args.footage_dur}s total={args.total_dur}s name={args.name}")
 
 
 
46
  if __name__ == "__main__":
47
  main()