hyperframes-video-studio / .github /workflows /render-testimonial.yml
AIGoose
fix(denoise): rewrite step 9 — extract audio only then remux, avoids ffmpeg 6.1 filter_complex video mapping bug
15ce6d7
Raw
History Blame Contribute Delete
8.48 kB
name: Render Testimonial Video
on:
workflow_dispatch:
inputs:
gdrive_file_id:
description: 'Google Drive file ID of testimonial MOV'
required: true
default: '1f1qlgyLJSCQgpjrWflaqldsNE2fZ-_1X'
participant_name:
description: 'Participant name'
required: false
default: 'Peserta Training'
participant_role:
description: 'Participant role / org'
required: false
default: 'AI Transformation Series 2026'
pull_quote:
description: 'Pull quote (Indonesian)'
required: false
default: 'Training ini langsung bisa dipraktek dan hasilnya nyata'
jobs:
render:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install system deps
run: |
sudo apt-get update -qq
sudo apt-get install -y ffmpeg python3-pip libass-dev fonts-liberation
pip3 install gdown huggingface_hub requests --quiet
fc-cache -fv 2>/dev/null || true
- name: Setup Node.js 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install HyperFrames + chrome-headless-shell
run: |
npm install -g hyperframes
npx hyperframes telemetry disable || true
SHELL_BIN=$(npx --yes @puppeteer/browsers install chrome-headless-shell@stable 2>&1 | tail -1 | awk '{print $NF}')
[ ! -x "$SHELL_BIN" ] && SHELL_BIN=$(find ~/.cache/puppeteer -name "chrome-headless-shell" -executable 2>/dev/null | head -1)
echo "HYPERFRAMES_BROWSER_PATH=$SHELL_BIN" >> $GITHUB_ENV
echo "Browser: $SHELL_BIN"
- name: Download MOV from Google Drive
run: |
mkdir -p assets project/assets
python3 - <<'PYEOF'
import gdown, os, sys
fid = "${{ github.event.inputs.gdrive_file_id }}"
out = "assets/raw.MOV"
gdown.download(f"https://drive.google.com/uc?id={fid}", out, quiet=False)
if not os.path.exists(out):
sys.exit(1)
print(f"Downloaded {os.path.getsize(out)/1024/1024:.1f} MB")
PYEOF
- name: Convert MOV to 720p MP4
run: |
ffmpeg -y -i assets/raw.MOV \
-vf "scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280" \
-c:v libx264 -preset fast -crf 22 \
-c:a aac -b:a 128k -movflags +faststart \
assets/source.mp4
echo "Source: $(du -sh assets/source.mp4)"
- name: Trim interviewer section
run: |
ffmpeg -y -i assets/source.mp4 \
-filter_complex "\
[0:v]trim=0:71,setpts=PTS-STARTPTS[v1];\
[0:a]atrim=0:71,asetpts=PTS-STARTPTS[a1];\
[0:v]trim=77:87,setpts=PTS-STARTPTS[v2];\
[0:a]atrim=77:87,asetpts=PTS-STARTPTS[a2];\
[v1][a1][v2][a2]concat=n=2:v=1:a=1[vout][aout]" \
-map "[vout]" -map "[aout]" \
-c:v libx264 -preset fast -crf 22 \
-c:a aac -b:a 128k -movflags +faststart \
assets/cut.mp4
echo "Cut: $(ffprobe -v quiet -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 assets/cut.mp4 | cut -d. -f1)s"
- name: Denoise audio (extract, filter, remux)
run: |
# Step A: extract audio only and apply anlmdn noise reduction
ffmpeg -y -i assets/cut.mp4 \
-vn -af "anlmdn=s=7:p=0.002:r=0.002:m=15" \
-c:a aac -b:a 128k \
assets/audio_clean.aac
# Step B: remux original video with clean audio
ffmpeg -y \
-i assets/cut.mp4 \
-i assets/audio_clean.aac \
-map 0:v -map 1:a \
-c:v copy -c:a copy \
-shortest \
assets/denoised.mp4
echo "Denoised: $(du -sh assets/denoised.mp4)"
- name: Set footage duration
run: |
DUR=$(ffprobe -v quiet -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 assets/denoised.mp4 | xargs printf "%.0f")
echo "FOOTAGE_DUR=$DUR" >> $GITHUB_ENV
echo "Footage duration: ${DUR}s"
- name: Transcribe and generate SRT captions
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
mkdir -p project/assets
python3 scripts/gen_captions.py \
--input assets/denoised.mp4 \
--srt-out assets/captions.srt
head -20 assets/captions.srt || true
- name: Burn captions into video
run: |
FONT_PATH=$(fc-list | grep -i "LiberationSans-Regular" | head -1 | cut -d: -f1)
[ -z "$FONT_PATH" ] && FONT_PATH="/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
echo "Font: $FONT_PATH"
ffmpeg -y -i assets/denoised.mp4 \
-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'" \
-c:v libx264 -preset fast -crf 22 \
-c:a copy \
assets/captioned.mp4
echo "Captioned: $(du -sh assets/captioned.mp4)"
- name: Mix background music
run: |
DUR=$FOOTAGE_DUR
# Generate lo-fi ambient tone (CC0 — no external download needed)
ffmpeg -y -f lavfi \
-i "aevalsrc=0.06*sin(2*PI*220*t)+0.04*sin(2*PI*330*t)+0.03*sin(2*PI*110*t):s=44100" \
-af "volume=0.10,lowpass=f=600,highpass=f=60,aecho=0.6:0.3:60:0.3" \
-t "${DUR}" -q:a 6 -acodec libmp3lame \
assets/music_gen.mp3
ffmpeg -y \
-i assets/captioned.mp4 \
-stream_loop -1 -i assets/music_gen.mp3 \
-filter_complex \
"[1:a]atrim=0:${DUR},asetpts=PTS-STARTPTS,volume=0.10[music];\
[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[aout]" \
-map "0:v" -map "[aout]" \
-c:v copy -c:a aac -b:a 128k \
assets/clean.mp4
TOTAL=$((DUR + 3))
echo "TOTAL_DUR=$TOTAL" >> $GITHUB_ENV
echo "clean.mp4: $(du -sh assets/clean.mp4) | footage=${DUR}s total=${TOTAL}s"
- name: Build project folder
run: |
cp assets/clean.mp4 project/assets/clean.mp4
python3 scripts/patch_composition.py \
--input compositions/testimonial.html \
--output project/index.html \
--name "${{ github.event.inputs.participant_name }}" \
--role "${{ github.event.inputs.participant_role }}" \
--quote "${{ github.event.inputs.pull_quote }}" \
--footage-dur "$FOOTAGE_DUR" \
--total-dur "$TOTAL_DUR"
echo "Project:"; ls -lh project/ project/assets/
- name: Lint composition
run: npx hyperframes lint project || true
- name: Render (720x1280 @ 24fps)
env:
HYPERFRAMES_BROWSER_PATH: ${{ env.HYPERFRAMES_BROWSER_PATH }}
run: |
mkdir -p output
TS=$(date +%Y%m%d-%H%M)
OUT="output/bright-studio-${TS}.mp4"
echo "OUTPUT_FILE=$OUT" >> $GITHUB_ENV
npx hyperframes render project \
--output "$OUT" --width 720 --height 1280 --fps 24 --quality standard
echo "Rendered: $OUT ($(du -sh $OUT | cut -f1))"
- name: Upload to Hugging Face
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
PARTICIPANT_NAME: ${{ github.event.inputs.participant_name }}
run: |
python3 - <<'PYEOF'
import os
from huggingface_hub import HfApi
api = HfApi(token=os.environ["HF_TOKEN"])
out = os.environ["OUTPUT_FILE"]
fname = os.path.basename(out)
api.upload_file(path_or_fileobj=out,
path_in_repo=f"renders/{fname}",
repo_id="AIgoose/video-renders", repo_type="dataset")
url = f"https://huggingface.co/datasets/AIgoose/video-renders/resolve/main/renders/{fname}"
print(f"Video ready: {url}")
print(f"::notice title=Video Ready::{url}")
PYEOF
- name: Upload artifact (backup)
uses: actions/upload-artifact@v4
with:
name: bright-studio-v3
path: output/*.mp4
retention-days: 14