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