Skip to content

Commit 7d94684

Browse files
feat: add guest promo video pipeline (Remotion + MAI-Voice-1)
- Copilot skill: /generate-guest-promo <issue_number> - extract_guest_metadata.py: pulls guest data from scheduled issue - generate_tts.py: generates narration via Azure Speech MAI-Voice-1 - Remotion project (video/): 30s 1080x1080 promo video component - GitHub Actions workflow: triggers on 'approved' label, renders MP4, uploads as artifact and comments download link on the issue Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cdb675f commit 7d94684

13 files changed

Lines changed: 655 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env python3
2+
"""Extract guest metadata from a scheduled issue and write guest-promo.json."""
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import re
8+
import sys
9+
import urllib.request
10+
from datetime import datetime
11+
12+
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
13+
REPO = "githubevents/open-source-friday"
14+
HOST_NAMES = {
15+
"AndreaGriffiths11": "Andrea Griffiths",
16+
"KevinCrosby": "Kevin Crosby",
17+
}
18+
19+
20+
def fetch_issue(number: int) -> dict:
21+
url = f"https://api.github.com/repos/{REPO}/issues/{number}"
22+
req = urllib.request.Request(url, headers={
23+
"Authorization": f"token {GITHUB_TOKEN}",
24+
"Accept": "application/vnd.github.v3+json",
25+
"User-Agent": "osf-guest-promo",
26+
})
27+
with urllib.request.urlopen(req) as resp:
28+
return json.loads(resp.read())
29+
30+
31+
def parse_field(body: str, field: str) -> str:
32+
m = re.search(rf"### {re.escape(field)}\s*\n+(.+?)(?:\n###|\Z)", body, re.DOTALL)
33+
if m:
34+
val = m.group(1).strip()
35+
if val.upper() not in ("_NO RESPONSE_", "TBD", "NOT YET", ""):
36+
return val
37+
return ""
38+
39+
40+
def parse_date(raw: str) -> str:
41+
for fmt in ("%m-%d-%Y", "%m-%d-%y", "%m/%d/%Y", "%B %d, %Y", "%B %-d, %Y"):
42+
try:
43+
return datetime.strptime(raw.strip(), fmt).strftime("%B %-d, %Y")
44+
except ValueError:
45+
continue
46+
return raw
47+
48+
49+
def main() -> None:
50+
if len(sys.argv) < 2:
51+
print("Usage: extract_guest_metadata.py <issue_number>")
52+
sys.exit(1)
53+
54+
number = int(sys.argv[1])
55+
issue = fetch_issue(number)
56+
body = issue.get("body") or ""
57+
title = issue.get("title") or ""
58+
59+
guest_name = parse_field(body, "Name")
60+
github_handle = parse_field(body, "GitHub Handle").lstrip("@")
61+
bio = parse_field(body, "Tell us about yourself")
62+
project_name = parse_field(body, "Project Name")
63+
project_url = parse_field(body, "Project Repo Link")
64+
raw_date = parse_field(body, "Dates")
65+
66+
# Calendly-style body: "Name: Angela Wen @handle"
67+
if not guest_name:
68+
m = re.search(r"Name:\s+(.+?)(?:\s*@\S+)?\s*$", body, re.MULTILINE)
69+
if m:
70+
guest_name = m.group(1).strip()
71+
72+
# Title-based date fallback
73+
if not raw_date or raw_date.upper() in ("TBD", "_NO RESPONSE_", "NOT YET", ""):
74+
m = re.search(r"(\d{1,2}[-/]\d{1,2}[-/]\d{2,4})", title)
75+
if m:
76+
raw_date = m.group(1)
77+
78+
stream_date = parse_date(raw_date) if raw_date else "Date TBD"
79+
80+
assignees = issue.get("assignees") or []
81+
host_name = "TBD"
82+
if assignees:
83+
login = assignees[0]["login"]
84+
host_name = HOST_NAMES.get(login, login)
85+
86+
# Truncate bio for video overlay
87+
if bio and len(bio) > 280:
88+
bio = bio[:277] + "..."
89+
90+
metadata = {
91+
"guest_name": guest_name or "Guest",
92+
"github_handle": github_handle,
93+
"project_name": project_name or "Open Source",
94+
"project_url": project_url,
95+
"bio": bio,
96+
"stream_date": stream_date,
97+
"stream_time": "1 PM ET",
98+
"host_name": host_name,
99+
"issue_number": number,
100+
"issue_url": issue["html_url"],
101+
"has_audio": False, # set to True by workflow after TTS succeeds
102+
}
103+
104+
output_path = os.environ.get("METADATA_OUTPUT", "video/public/guest-promo.json")
105+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
106+
with open(output_path, "w") as f:
107+
json.dump(metadata, f, indent=2)
108+
109+
print(f"✅ Wrote metadata for '{guest_name}' → {output_path}")
110+
print(json.dumps(metadata, indent=2))
111+
112+
113+
if __name__ == "__main__":
114+
main()

.github/scripts/generate_tts.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env python3
2+
"""Generate TTS narration for a guest promo using MAI-Voice-1 via Azure Speech API."""
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import sys
8+
import urllib.request
9+
10+
AZURE_TTS_KEY = os.environ.get("AZURE_TTS_KEY", "")
11+
AZURE_TTS_ENDPOINT = os.environ.get("AZURE_TTS_ENDPOINT", "")
12+
AZURE_TTS_VOICE = os.environ.get("AZURE_TTS_VOICE", "mai-voice-1")
13+
14+
SSML_TEMPLATE = """\
15+
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US">
16+
<voice name="{voice}">
17+
<prosody rate="medium" pitch="medium">
18+
{text}
19+
</prosody>
20+
</voice>
21+
</speak>"""
22+
23+
24+
def generate_tts(text: str, output_path: str) -> None:
25+
if not AZURE_TTS_KEY or not AZURE_TTS_ENDPOINT:
26+
print("❌ AZURE_TTS_KEY or AZURE_TTS_ENDPOINT not set — skipping TTS.")
27+
sys.exit(1)
28+
29+
ssml = SSML_TEMPLATE.format(voice=AZURE_TTS_VOICE, text=text)
30+
req = urllib.request.Request(
31+
AZURE_TTS_ENDPOINT,
32+
data=ssml.encode("utf-8"),
33+
headers={
34+
"Ocp-Apim-Subscription-Key": AZURE_TTS_KEY,
35+
"Content-Type": "application/ssml+xml",
36+
"X-Microsoft-OutputFormat": "audio-24khz-48kbitrate-mono-mp3",
37+
"User-Agent": "osf-guest-promo",
38+
},
39+
method="POST",
40+
)
41+
42+
with urllib.request.urlopen(req) as resp:
43+
audio_data = resp.read()
44+
45+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
46+
with open(output_path, "wb") as f:
47+
f.write(audio_data)
48+
print(f"✅ Generated narration ({len(audio_data)} bytes) → {output_path}")
49+
50+
51+
def main() -> None:
52+
metadata_path = os.environ.get("METADATA_OUTPUT", "video/public/guest-promo.json")
53+
with open(metadata_path) as f:
54+
meta = json.load(f)
55+
56+
text = (
57+
f"Join us on Open Source Friday! "
58+
f"We're welcoming {meta['guest_name']}, "
59+
f"who will be talking about {meta['project_name']}. "
60+
f"Stream live on YouTube on {meta['stream_date']} at {meta['stream_time']}. "
61+
f"Don't miss it!"
62+
)
63+
64+
output_path = os.environ.get("NARRATION_OUTPUT", "video/public/narration.mp3")
65+
generate_tts(text, output_path)
66+
67+
# Mark has_audio = True in metadata so Remotion includes the audio track
68+
meta["has_audio"] = True
69+
with open(metadata_path, "w") as f:
70+
json.dump(meta, f, indent=2)
71+
print("✅ Updated guest-promo.json: has_audio = true")
72+
73+
74+
if __name__ == "__main__":
75+
main()
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
name: generate-guest-promo
3+
description: Generates a 30-second promo video for an upcoming Open Source Friday guest stream using MAI-Voice-1 TTS and Remotion.
4+
user-invocable: true
5+
---
6+
7+
## Purpose
8+
9+
Generate a 30-second promotional video for an upcoming Open Source Friday guest, ready to post on social media.
10+
11+
## Instructions
12+
13+
When invoked as `/generate-guest-promo <issue_number>`:
14+
15+
1. Confirm Node.js and Python 3 are available
16+
2. Run `cd video && npm install` if `node_modules` doesn't exist
17+
3. Ensure these environment variables are set (prompt the user if missing):
18+
- `GITHUB_TOKEN` — for fetching issue data
19+
- `AZURE_TTS_KEY` — Azure Speech API key
20+
- `AZURE_TTS_ENDPOINT` — Azure Speech TTS endpoint URL
21+
- `AZURE_TTS_VOICE` — voice name (default: `mai-voice-1`)
22+
4. Extract guest metadata:
23+
```
24+
python3 .github/scripts/extract_guest_metadata.py <issue_number>
25+
```
26+
5. Generate TTS narration:
27+
```
28+
python3 .github/scripts/generate_tts.py
29+
```
30+
6. Render the promo video:
31+
```
32+
cd video && npm run render
33+
```
34+
7. Report the output path: `video/out/guest-promo.mp4`
35+
36+
## Output Files
37+
38+
| File | Description |
39+
|------|-------------|
40+
| `video/public/guest-promo.json` | Extracted guest metadata |
41+
| `video/public/narration.mp3` | MAI-Voice-1 TTS narration |
42+
| `video/out/guest-promo.mp4` | Final 30-second promo video |
43+
44+
## Example
45+
46+
```
47+
/generate-guest-promo 222
48+
```
49+
50+
Generates a promo for issue #222 (Angela Wen – How to contribute to OSS, June 19).
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: Generate Guest Promo Video
2+
3+
on:
4+
issues:
5+
types: [labeled]
6+
workflow_dispatch:
7+
inputs:
8+
issue_number:
9+
description: "Issue number to generate promo for"
10+
required: true
11+
type: number
12+
13+
jobs:
14+
generate-promo:
15+
# Run when labeled "approved", or on manual dispatch
16+
if: github.event.label.name == 'approved' || github.event_name == 'workflow_dispatch'
17+
runs-on: ubuntu-latest
18+
permissions:
19+
issues: write
20+
contents: read
21+
22+
steps:
23+
- uses: actions/checkout@v4
24+
25+
- uses: actions/setup-python@v5
26+
with:
27+
python-version: "3.12"
28+
29+
- uses: actions/setup-node@v4
30+
with:
31+
node-version: "20"
32+
cache: "npm"
33+
cache-dependency-path: video/package.json
34+
35+
- name: Install Remotion dependencies
36+
run: cd video && npm install
37+
38+
- name: Extract guest metadata
39+
env:
40+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
41+
METADATA_OUTPUT: video/public/guest-promo.json
42+
run: |
43+
ISSUE=${{ github.event.issue.number || inputs.issue_number }}
44+
echo "Extracting metadata for issue #$ISSUE"
45+
python3 .github/scripts/extract_guest_metadata.py $ISSUE
46+
47+
- name: Generate TTS narration (MAI-Voice-1)
48+
env:
49+
AZURE_TTS_KEY: ${{ secrets.AZURE_TTS_KEY }}
50+
AZURE_TTS_ENDPOINT: ${{ secrets.AZURE_TTS_ENDPOINT }}
51+
AZURE_TTS_VOICE: ${{ secrets.AZURE_TTS_VOICE }}
52+
METADATA_OUTPUT: video/public/guest-promo.json
53+
NARRATION_OUTPUT: video/public/narration.mp3
54+
run: python3 .github/scripts/generate_tts.py
55+
56+
- name: Render promo video
57+
run: |
58+
PROPS=$(cat video/public/guest-promo.json)
59+
cd video
60+
npx remotion render src/index.ts GuestPromo out/guest-promo.mp4 \
61+
--props="$PROPS" \
62+
--gl=angle
63+
64+
- name: Upload promo video artifact
65+
uses: actions/upload-artifact@v4
66+
with:
67+
name: guest-promo-${{ github.event.issue.number || inputs.issue_number }}
68+
path: video/out/guest-promo.mp4
69+
retention-days: 30
70+
71+
- name: Comment on issue with download link
72+
if: github.event_name == 'issues'
73+
uses: actions/github-script@v7
74+
with:
75+
script: |
76+
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
77+
github.rest.issues.createComment({
78+
issue_number: context.issue.number,
79+
owner: context.repo.owner,
80+
repo: context.repo.repo,
81+
body: `🎬 **Guest promo video generated!**\n\nDownload \`guest-promo.mp4\` from the [workflow artifacts](${runUrl}) (available for 30 days).`,
82+
});

video/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Generated files — do not commit
2+
public/narration.mp3
3+
public/guest-promo.json
4+
out/
5+
node_modules/

video/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "osf-guest-promo",
3+
"version": "1.0.0",
4+
"description": "Open Source Friday guest promo video generator",
5+
"scripts": {
6+
"studio": "npx remotion studio",
7+
"render": "npx remotion render src/index.ts GuestPromo out/guest-promo.mp4 --gl=angle"
8+
},
9+
"dependencies": {
10+
"@remotion/cli": "4.0.290",
11+
"remotion": "4.0.290"
12+
},
13+
"devDependencies": {
14+
"@types/react": "^18.3.0",
15+
"@types/react-dom": "^18.3.0",
16+
"react": "^18.3.0",
17+
"react-dom": "^18.3.0",
18+
"typescript": "^5.4.0"
19+
}
20+
}

video/public/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# keep public/ tracked but ignore generated files

video/remotion.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Config } from "@remotion/cli/config";
2+
3+
Config.setVideoImageFormat("jpeg");
4+
Config.setOverwriteOutput(true);
5+
Config.setConcurrency(1);

0 commit comments

Comments
 (0)