snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
279
skills/pr-walkthrough/scripts/generate-audio.sh
Executable file
279
skills/pr-walkthrough/scripts/generate-audio.sh
Executable file
@@ -0,0 +1,279 @@
|
||||
#!/bin/bash
|
||||
# generate-audio.sh — Generate walkthrough narration audio from a JSON script.
|
||||
#
|
||||
# Generates one TTS call per segment, producing individual WAV clips directly.
|
||||
# No chunking, alignment, or splitting needed.
|
||||
#
|
||||
# Usage:
|
||||
# ./generate-audio.sh <script.json> [output-dir]
|
||||
#
|
||||
# Input JSON format:
|
||||
# {
|
||||
# "style": "Read in a calm, steady, professional tone...",
|
||||
# "voice": "Iapetus", (optional, default: Iapetus)
|
||||
# "slides": [
|
||||
# "Intro narration text...",
|
||||
# "Problem slide narration...",
|
||||
# "Approach narration...",
|
||||
# ...
|
||||
# ]
|
||||
# }
|
||||
#
|
||||
# Output:
|
||||
# <output-dir>/audio-00.wav, audio-01.wav, ...
|
||||
# <output-dir>/durations.json
|
||||
#
|
||||
# Dependencies:
|
||||
# ffmpeg / ffprobe
|
||||
#
|
||||
# Environment:
|
||||
# GEMINI_API_KEY — required. Auto-sourced from .env if not set.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# --- Args ---
|
||||
SCRIPT_JSON="${1:?Usage: generate-audio.sh <script.json> [output-dir]}"
|
||||
OUTPUT_DIR="${2:-.}"
|
||||
|
||||
# Resolve relative paths
|
||||
[[ "$SCRIPT_JSON" != /* ]] && SCRIPT_JSON="$(pwd)/$SCRIPT_JSON"
|
||||
[[ "$OUTPUT_DIR" != /* ]] && OUTPUT_DIR="$(pwd)/$OUTPUT_DIR"
|
||||
|
||||
if [ ! -f "$SCRIPT_JSON" ]; then
|
||||
echo "Error: ${SCRIPT_JSON} not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
PYTHON="python3"
|
||||
|
||||
# --- API key ---
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
|
||||
|
||||
if [ -z "${GEMINI_API_KEY:-}" ]; then
|
||||
if [ -f "${REPO_ROOT}/.env" ]; then
|
||||
export $(grep '^GEMINI_API_KEY=' "${REPO_ROOT}/.env" | xargs) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
GEMINI_API_KEY="${GEMINI_API_KEY:?Set GEMINI_API_KEY environment variable or add it to .env}"
|
||||
|
||||
# --- Config ---
|
||||
TTS_MODEL="gemini-3.1-flash-tts-preview"
|
||||
TTS_ENDPOINT="https://generativelanguage.googleapis.com/v1beta/models/${TTS_MODEL}:generateContent"
|
||||
SPEED=1.2 # Speed up narration (1.0 = no change)
|
||||
|
||||
# --- Run everything in Python for reliability ---
|
||||
"$PYTHON" - "$SCRIPT_JSON" "$OUTPUT_DIR" "$GEMINI_API_KEY" "$TTS_MODEL" "$TTS_ENDPOINT" "$SPEED" <<'PYTHON_SCRIPT'
|
||||
import json, sys, os, subprocess, base64, urllib.request, re
|
||||
|
||||
script_json = sys.argv[1]
|
||||
output_dir = sys.argv[2]
|
||||
api_key = sys.argv[3]
|
||||
tts_model = sys.argv[4]
|
||||
tts_endpoint = sys.argv[5]
|
||||
speed = float(sys.argv[6])
|
||||
|
||||
MAX_RETRIES = 2
|
||||
|
||||
def api_call(endpoint, body_dict):
|
||||
body = json.dumps(body_dict).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{endpoint}?key={api_key}",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
# --- Load narration ---
|
||||
with open(script_json) as f:
|
||||
data = json.load(f)
|
||||
|
||||
voice = data.get("voice", "Iapetus")
|
||||
slides = data["slides"]
|
||||
style = data.get("style",
|
||||
"Read the following in a calm, steady, professional tone. "
|
||||
"Speak at a measured pace.")
|
||||
|
||||
word_count = sum(len(s.split()) for s in slides)
|
||||
print(f"=== Generating narration audio ===")
|
||||
print(f" Voice: {voice}")
|
||||
print(f" Slides: {len(slides)}")
|
||||
print(f" Words: {word_count}")
|
||||
print()
|
||||
|
||||
def call_tts(prompt_text):
|
||||
"""Make a single TTS API call and return raw PCM bytes."""
|
||||
response = api_call(tts_endpoint, {
|
||||
"contents": [{"parts": [{"text": prompt_text}]}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["AUDIO"],
|
||||
"speechConfig": {
|
||||
"voiceConfig": {
|
||||
"prebuiltVoiceConfig": {
|
||||
"voiceName": voice
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
error_msg = response.get("error", {}).get("message", "")
|
||||
if error_msg:
|
||||
raise RuntimeError(f"TTS API error: {error_msg}")
|
||||
|
||||
return base64.b64decode(response["candidates"][0]["content"]["parts"][0]["inlineData"]["data"])
|
||||
|
||||
def pcm_to_wav(pcm_bytes, out_wav):
|
||||
"""Convert raw 24kHz PCM to 48kHz WAV with speed adjustment."""
|
||||
pcm_tmp = out_wav + ".pcm"
|
||||
with open(pcm_tmp, "wb") as f:
|
||||
f.write(pcm_bytes)
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-f", "s16le", "-ar", "24000", "-ac", "1",
|
||||
"-i", pcm_tmp, "-af", f"atempo={speed}", "-ar", "48000", out_wav
|
||||
], capture_output=True, check=True)
|
||||
os.remove(pcm_tmp)
|
||||
|
||||
def get_duration(wav_path):
|
||||
"""Get duration of a WAV file in seconds."""
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", wav_path],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
def validate_duration(wav_path, word_count):
|
||||
"""Check if audio duration is reasonable for the word count. Returns (ok, duration)."""
|
||||
dur = get_duration(wav_path)
|
||||
# At ~150 wpm before speed adjustment, expect word_count/150*60/speed seconds.
|
||||
# Allow generous bounds: 0.5x to 3x expected.
|
||||
expected = word_count / 150 * 60 / speed
|
||||
lower = expected * 0.3
|
||||
upper = expected * 3.0
|
||||
# For very short segments (< 15 words), just check it's under 30s
|
||||
if word_count < 15:
|
||||
return dur < 30, dur
|
||||
return lower <= dur <= upper, dur
|
||||
|
||||
# --- Generate one TTS call per segment ---
|
||||
durations = {}
|
||||
|
||||
for i, text in enumerate(slides):
|
||||
num = f"{i:02d}"
|
||||
out_path = os.path.join(output_dir, f"audio-{num}.wav")
|
||||
wc = len(text.split())
|
||||
prompt = f"{style}\n\n{text}"
|
||||
|
||||
ok = False
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
label = f" [{num}] " + ("" if attempt == 0 else f"(retry {attempt}) ")
|
||||
print(f"{label}Generating ({wc} words)...", end=" ", flush=True)
|
||||
pcm_data = call_tts(prompt)
|
||||
pcm_to_wav(pcm_data, out_path)
|
||||
ok, dur = validate_duration(out_path, wc)
|
||||
if ok:
|
||||
print(f"{dur:.1f}s")
|
||||
durations[f"audio-{num}.wav"] = round(dur, 2)
|
||||
break
|
||||
else:
|
||||
expected = wc / 150 * 60 / speed
|
||||
print(f"{dur:.1f}s (expected ~{expected:.0f}s, retrying)")
|
||||
except (urllib.error.HTTPError, RuntimeError) as e:
|
||||
print(f"error: {e}")
|
||||
if attempt == MAX_RETRIES:
|
||||
print(f" [error] Segment {i} failed after {MAX_RETRIES + 1} attempts")
|
||||
sys.exit(1)
|
||||
|
||||
if not ok:
|
||||
# Use last attempt even if validation failed — warn but continue
|
||||
dur = get_duration(out_path)
|
||||
durations[f"audio-{num}.wav"] = round(dur, 2)
|
||||
print(f" [warn] Segment {i} audio may be unreliable ({dur:.1f}s for {wc} words)")
|
||||
|
||||
# --- Trim silence from each clip ---
|
||||
MAX_SILENCE = 0.15 # strip nearly all silence — segment title slides handle inter-slide pauses
|
||||
SILENCE_THRESHOLD = "-40dB"
|
||||
print()
|
||||
print("=== Trimming silence ===")
|
||||
|
||||
for i in range(len(slides)):
|
||||
num = f"{i:02d}"
|
||||
clip_path = os.path.join(output_dir, f"audio-{num}.wav")
|
||||
|
||||
# Detect silence at start and end using silencedetect
|
||||
detect = subprocess.run([
|
||||
"ffmpeg", "-i", clip_path, "-af",
|
||||
f"silencedetect=noise={SILENCE_THRESHOLD}:d=0.1",
|
||||
"-f", "null", "-"
|
||||
], capture_output=True, text=True)
|
||||
stderr = detect.stderr
|
||||
|
||||
# Get clip duration
|
||||
clip_dur = get_duration(clip_path)
|
||||
|
||||
# Parse silence periods
|
||||
silence_starts = re.findall(r'silence_start: ([\d.]+)', stderr)
|
||||
silence_ends = re.findall(r'silence_end: ([\d.]+)', stderr)
|
||||
|
||||
# Find leading silence: silence that starts at ~0
|
||||
trim_start = 0.0
|
||||
if silence_starts and float(silence_starts[0]) < 0.05:
|
||||
if silence_ends:
|
||||
leading_silence = float(silence_ends[0])
|
||||
if leading_silence > MAX_SILENCE:
|
||||
trim_start = leading_silence - MAX_SILENCE
|
||||
|
||||
# Find trailing silence: silence that starts near the end and extends to clip end.
|
||||
# Skip trailing trim on last segment — it's followed by the outro and
|
||||
# the silence detector often clips the final word.
|
||||
trim_end = clip_dur
|
||||
is_last = (i == len(slides) - 1)
|
||||
if not is_last and silence_starts:
|
||||
last_silence_start = float(silence_starts[-1])
|
||||
# Verify this silence actually extends to the clip end (not an internal pause).
|
||||
last_silence_is_trailing = True
|
||||
for se in silence_ends:
|
||||
se_val = float(se)
|
||||
if se_val > last_silence_start and se_val < clip_dur - 0.05:
|
||||
last_silence_is_trailing = False
|
||||
break
|
||||
if last_silence_is_trailing and last_silence_start > 0.05: # not the leading silence
|
||||
trailing_silence = clip_dur - last_silence_start
|
||||
if trailing_silence > MAX_SILENCE:
|
||||
trim_end = last_silence_start + MAX_SILENCE
|
||||
|
||||
if trim_start > 0 or trim_end < clip_dur:
|
||||
trimmed_path = clip_path + ".tmp.wav"
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-i", clip_path,
|
||||
"-ss", str(trim_start), "-to", str(trim_end),
|
||||
"-c", "copy", trimmed_path
|
||||
], capture_output=True)
|
||||
os.replace(trimmed_path, clip_path)
|
||||
new_dur = trim_end - trim_start
|
||||
durations[f"audio-{num}.wav"] = round(new_dur, 2)
|
||||
print(f" audio-{num}.wav: {clip_dur:.1f}s -> {new_dur:.1f}s (trimmed {clip_dur - new_dur:.1f}s)")
|
||||
else:
|
||||
print(f" audio-{num}.wav: {clip_dur:.1f}s (no trim needed)")
|
||||
|
||||
# --- Write durations.json ---
|
||||
durations_path = os.path.join(output_dir, "durations.json")
|
||||
with open(durations_path, "w") as f:
|
||||
json.dump(durations, f, indent=2)
|
||||
|
||||
total_dur = sum(durations.values())
|
||||
print(f"\n Wrote durations.json ({len(durations)} entries, {total_dur:.1f}s total)")
|
||||
|
||||
print()
|
||||
print("=== Done ===")
|
||||
PYTHON_SCRIPT
|
||||
|
||||
echo ""
|
||||
echo "Output:"
|
||||
ls -la "${OUTPUT_DIR}"/audio-*.wav 2>/dev/null || echo " (no files generated)"
|
||||
89
skills/pr-walkthrough/scripts/make-video.sh
Executable file
89
skills/pr-walkthrough/scripts/make-video.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
# make-video.sh — Assemble walkthrough slides + audio into a final MP4.
|
||||
#
|
||||
# Usage:
|
||||
# ./make-video.sh <slide-dir> <output.mp4> [outro-duration]
|
||||
#
|
||||
# Expects in <slide-dir>:
|
||||
# slide-00.png, slide-01.png, ... (one per segment, including outro)
|
||||
# audio-00.wav, audio-01.wav, ... (one per narrated segment)
|
||||
#
|
||||
# The last slide PNG without a matching audio WAV is the silent outro.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SLIDE_DIR="${1:?Usage: make-video.sh <slide-dir> <output.mp4> [outro-duration]}"
|
||||
OUTPUT="${2:?Usage: make-video.sh <slide-dir> <output.mp4> [outro-duration]}"
|
||||
OUTRO_DUR="${3:-3}"
|
||||
|
||||
# Resolve relative paths
|
||||
[[ "$SLIDE_DIR" != /* ]] && SLIDE_DIR="$(pwd)/$SLIDE_DIR"
|
||||
[[ "$OUTPUT" != /* ]] && OUTPUT="$(pwd)/$OUTPUT"
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT")"
|
||||
|
||||
TMPDIR_WORK=$(mktemp -d)
|
||||
trap "rm -rf $TMPDIR_WORK" EXIT
|
||||
|
||||
echo "=== Assembling video ==="
|
||||
echo " Slides: $SLIDE_DIR"
|
||||
echo " Output: $OUTPUT"
|
||||
|
||||
# Count slides and audio
|
||||
SLIDE_COUNT=$(ls "$SLIDE_DIR"/slide-*.png 2>/dev/null | wc -l | tr -d ' ')
|
||||
AUDIO_COUNT=$(ls "$SLIDE_DIR"/audio-*.wav 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
echo " Found $SLIDE_COUNT slides, $AUDIO_COUNT audio clips"
|
||||
echo " Last slide (no audio) = outro (${OUTRO_DUR}s)"
|
||||
|
||||
# Create per-segment videos
|
||||
CONCAT_LIST="$TMPDIR_WORK/concat.txt"
|
||||
> "$CONCAT_LIST"
|
||||
|
||||
for i in $(seq 0 $((SLIDE_COUNT - 1))); do
|
||||
NUM=$(printf "%02d" $i)
|
||||
SLIDE="$SLIDE_DIR/slide-${NUM}.png"
|
||||
AUDIO="$SLIDE_DIR/audio-${NUM}.wav"
|
||||
SEGMENT="$TMPDIR_WORK/segment-${NUM}.mp4"
|
||||
|
||||
if [ -f "$AUDIO" ]; then
|
||||
# Get audio duration
|
||||
DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$AUDIO")
|
||||
echo " segment-${NUM}: slide + audio (${DUR}s)"
|
||||
|
||||
# Create video segment: static image for duration of audio, with audio
|
||||
ffmpeg -y -loop 1 -i "$SLIDE" -i "$AUDIO" \
|
||||
-c:v libx264 -tune stillimage -pix_fmt yuv420p \
|
||||
-vf "scale=1600:900:force_original_aspect_ratio=decrease,pad=1600:900:(ow-iw)/2:(oh-ih)/2" \
|
||||
-c:a aac -b:a 192k -ar 48000 \
|
||||
-shortest -movflags +faststart \
|
||||
"$SEGMENT" 2>/dev/null
|
||||
else
|
||||
# Silent outro slide
|
||||
echo " segment-${NUM}: silent outro (${OUTRO_DUR}s)"
|
||||
ffmpeg -y -loop 1 -i "$SLIDE" -f lavfi -i anullsrc=r=48000:cl=mono \
|
||||
-c:v libx264 -tune stillimage -pix_fmt yuv420p \
|
||||
-vf "scale=1600:900:force_original_aspect_ratio=decrease,pad=1600:900:(ow-iw)/2:(oh-ih)/2" \
|
||||
-c:a aac -b:a 192k -ar 48000 \
|
||||
-t "$OUTRO_DUR" -movflags +faststart \
|
||||
"$SEGMENT" 2>/dev/null
|
||||
fi
|
||||
|
||||
echo "file '$SEGMENT'" >> "$CONCAT_LIST"
|
||||
done
|
||||
|
||||
# Concatenate all segments
|
||||
echo ""
|
||||
echo " Concatenating ${SLIDE_COUNT} segments..."
|
||||
ffmpeg -y -f concat -safe 0 -i "$CONCAT_LIST" \
|
||||
-c copy -movflags +faststart \
|
||||
"$OUTPUT" 2>/dev/null
|
||||
|
||||
# Report
|
||||
FINAL_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUTPUT")
|
||||
FINAL_SIZE=$(ls -lh "$OUTPUT" | awk '{print $5}')
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo " Output: $OUTPUT"
|
||||
echo " Duration: ${FINAL_DUR}s"
|
||||
echo " Size: $FINAL_SIZE"
|
||||
Reference in New Issue
Block a user