#!/usr/bin/env bash # send_video_note.sh — Generate TTS + lip-sync video (VEED Fabric 1.0) and send as Telegram video note # # Usage: # ./send_video_note.sh "Текст для озвучки" # ./send_video_note.sh "Текст" [VOICE_ID] [CHAT_ID] # # Dependencies: # - curl, python3, base64 # - ffmpeg (path in FFMPEG_BIN) # - ELEVENLABS_API_KEY in ~/.openclaw/.env # - FAL_KEY in ~/.openclaw/.env (format: key_id:key_secret) # - BOT_TOKEN read from ~/.openclaw/openclaw.json set -euo pipefail # ── Config ──────────────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="$HOME/.openclaw/.env" CONFIG_FILE="$HOME/.openclaw/openclaw.json" AVATAR="$HOME/.openclaw/workspace/data/avatars/stream-avatar.jpg" FFMPEG_BIN="/home/node/bin/ffmpeg-7.0.2-amd64-static/ffmpeg" FFPROBE_BIN="/home/node/bin/ffmpeg-7.0.2-amd64-static/ffprobe" DEFAULT_VOICE="TPIitICAZ8CqlGZ81AKm" DEFAULT_MODEL="eleven_multilingual_v2" VIDEO_SIZE=512 # Telegram video note max dimension MAX_DURATION=60 # Telegram video note max seconds FAL_RESOLUTION="720p" # VEED Fabric 1.0: 720p or 480p FAL_POLL_INTERVAL=5 # seconds between status polls FAL_MAX_WAIT=300 # max seconds to wait for fal.ai result TMP_DIR=$(mktemp -d /tmp/video_note_XXXXXX) LOG_TAG="[video_note]" # ── Cleanup on exit ─────────────────────────────────────────────────────────── cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT # ── Logging ─────────────────────────────────────────────────────────────────── log() { echo "$LOG_TAG $*" >&2; } err() { echo "$LOG_TAG ERROR: $*" >&2; } die() { err "$*"; exit 1; } # ── Argument parsing ────────────────────────────────────────────────────────── TEXT="${1:-}" VOICE_ID="${2:-$DEFAULT_VOICE}" CHAT_ID="${3:-}" [[ -z "$TEXT" ]] && die "Usage: $0 \"текст для озвучки\" [voice_id] [chat_id]" # ── Load environment ────────────────────────────────────────────────────────── [[ -f "$ENV_FILE" ]] || die "Env file not found: $ENV_FILE" # shellcheck source=/dev/null set -a; source "$ENV_FILE"; set +a [[ -n "${ELEVENLABS_API_KEY:-}" ]] || die "ELEVENLABS_API_KEY not set in $ENV_FILE" [[ -n "${FAL_KEY:-}" ]] || die "FAL_KEY not set in $ENV_FILE" # ── Get bot token and chat ID from config ───────────────────────────────────── get_bot_token() { python3 -c " import json, sys with open('$CONFIG_FILE') as f: d = json.load(f) accounts = d.get('channels', {}).get('telegram', {}).get('accounts', {}) for name in ['default', list(accounts.keys())[0] if accounts else None]: if name and name in accounts: tok = accounts[name].get('botToken', '') if tok: print(tok) sys.exit(0) sys.exit(1) " 2>/dev/null } BOT_TOKEN="$(get_bot_token)" || die "Cannot read bot token from $CONFIG_FILE" [[ -n "$BOT_TOKEN" ]] || die "Bot token is empty" if [[ -z "$CHAT_ID" ]]; then if [[ -n "${VOICE_TTS_TARGET:-}" ]]; then CHAT_ID="${VOICE_TTS_TARGET#telegram:}" fi fi [[ -n "$CHAT_ID" ]] || die "CHAT_ID not provided. Pass as arg 3 or set VOICE_TTS_TARGET=telegram:" # ── Sanity checks ───────────────────────────────────────────────────────────── [[ -f "$AVATAR" ]] || die "Avatar not found: $AVATAR" [[ -f "$FFMPEG_BIN" ]] || die "FFmpeg not found: $FFMPEG_BIN" command -v curl >/dev/null 2>&1 || die "curl not found" command -v python3 >/dev/null 2>&1 || die "python3 not found" command -v base64 >/dev/null 2>&1 || die "base64 not found" # ── Step 1: Generate TTS via ElevenLabs ────────────────────────────────────── AUDIO_FILE="$TMP_DIR/tts.mp3" log "Step 1: Generating TTS (voice=$VOICE_ID)..." HTTP_CODE=$(curl -s \ -X POST "https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}" \ -H "xi-api-key: $ELEVENLABS_API_KEY" \ -H "Content-Type: application/json" \ -d "$(python3 -c " import json, sys payload = { 'text': sys.argv[1], 'model_id': '$DEFAULT_MODEL', 'voice_settings': {'stability': 0.5, 'similarity_boost': 0.75} } print(json.dumps(payload)) " "$TEXT")" \ -o "$AUDIO_FILE" \ -w "%{http_code}") if [[ "$HTTP_CODE" != "200" ]]; then RESP_BODY=$(cat "$AUDIO_FILE" 2>/dev/null || echo "") die "ElevenLabs TTS failed (HTTP $HTTP_CODE): $RESP_BODY" fi AUDIO_SIZE=$(stat -c%s "$AUDIO_FILE" 2>/dev/null || stat -f%z "$AUDIO_FILE") [[ "$AUDIO_SIZE" -gt 1024 ]] || die "TTS audio too small (${AUDIO_SIZE} bytes) — likely an error response" log "✓ TTS audio: $AUDIO_FILE (${AUDIO_SIZE} bytes)" # ── Step 2: Get audio duration ──────────────────────────────────────────────── DURATION=$("$FFPROBE_BIN" -v quiet -show_entries format=duration \ -of default=noprint_wrappers=1:nokey=1 "$AUDIO_FILE" 2>/dev/null || echo "0") DURATION_INT=$(python3 -c "import math; print(min(int(math.ceil(float('$DURATION'))), $MAX_DURATION))") log "✓ Audio duration: ${DURATION}s (video capped at ${DURATION_INT}s)" # ── Step 3: Upload files to fal.ai storage ──────────────────────────────────── # fal.ai requires publicly accessible URLs — upload via fal.ai storage log "Step 3: Uploading files to fal.ai storage..." upload_to_fal() { local file_path="$1" local mime_type="$2" local file_name="$3" # Upload via fal.ai storage endpoint (CDN v3) local upload_response upload_response=$(curl -s -w "\n%{http_code}" --max-time 30 \ -X POST "https://rest.fal.ai/storage/upload/initiate?storage_type=fal-cdn-v3" \ -H "Authorization: Key ${FAL_KEY}" \ -H "Content-Type: application/json" \ -d "{\"content_type\": \"${mime_type}\", \"file_name\": \"${file_name}\"}" 2>&1) local http_code http_code=$(echo "$upload_response" | tail -1) local body body=$(echo "$upload_response" | head -n -1) if [[ "$http_code" != "200" ]]; then # Check for balance exhaustion if echo "$body" | grep -qi "balance\|billing\|locked"; then err "fal.ai account issue (HTTP $http_code): balance exhausted or locked. Top up at fal.ai/dashboard/billing" else err "fal.ai storage initiate failed (HTTP $http_code): $body" fi return 1 fi local upload_url upload_url=$(echo "$body" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('upload_url',''))" 2>/dev/null) local file_url file_url=$(echo "$body" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('file_url',''))" 2>/dev/null) if [[ -z "$upload_url" ]]; then err "No upload_url in fal.ai response: $body" return 1 fi # Upload the actual file local put_code put_code=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "$upload_url" \ -H "Content-Type: ${mime_type}" \ --data-binary "@${file_path}" 2>&1) if [[ "$put_code" != "200" && "$put_code" != "204" ]]; then err "fal.ai file upload failed (HTTP $put_code)" return 1 fi echo "$file_url" } # Upload avatar image log " Uploading avatar image..." IMAGE_URL=$(upload_to_fal "$AVATAR" "image/jpeg" "avatar.jpg") || { log " fal.ai storage upload failed, trying base64 fallback..." # Base64 fallback: encode image as data URI IMAGE_B64=$(base64 -w 0 "$AVATAR") IMAGE_URL="data:image/jpeg;base64,${IMAGE_B64}" log " Using base64 data URI for image ($(echo -n "$IMAGE_URL" | wc -c) chars)" } log "✓ Image URL ready" # Upload audio file log " Uploading TTS audio..." AUDIO_URL=$(upload_to_fal "$AUDIO_FILE" "audio/mpeg" "tts.mp3") || { log " fal.ai storage upload failed, trying base64 fallback..." AUDIO_B64=$(base64 -w 0 "$AUDIO_FILE") AUDIO_URL="data:audio/mpeg;base64,${AUDIO_B64}" log " Using base64 data URI for audio ($(echo -n "$AUDIO_URL" | wc -c) chars)" } log "✓ Audio URL ready" # ── Step 4: Submit VEED Fabric 1.0 job ─────────────────────────────────────── log "Step 4: Submitting VEED Fabric 1.0 lip sync job..." FAL_PAYLOAD=$(python3 -c " import json, sys payload = { 'image_url': sys.argv[1], 'audio_url': sys.argv[2], 'resolution': '$FAL_RESOLUTION' } print(json.dumps(payload)) " "$IMAGE_URL" "$AUDIO_URL") SUBMIT_RESPONSE=$(curl -s -w "\n%{http_code}" \ -X POST "https://queue.fal.run/veed/fabric-1.0" \ -H "Authorization: Key ${FAL_KEY}" \ -H "Content-Type: application/json" \ -d "$FAL_PAYLOAD" 2>&1) SUBMIT_HTTP=$(echo "$SUBMIT_RESPONSE" | tail -1) SUBMIT_BODY=$(echo "$SUBMIT_RESPONSE" | head -n -1) if [[ "$SUBMIT_HTTP" != "200" && "$SUBMIT_HTTP" != "201" ]]; then if echo "$SUBMIT_BODY" | grep -qi "balance\|billing\|locked\|exhausted"; then die "fal.ai account issue: balance exhausted or locked. Top up at https://fal.ai/dashboard/billing" fi die "fal.ai submit failed (HTTP $SUBMIT_HTTP): $SUBMIT_BODY" fi REQUEST_ID=$(echo "$SUBMIT_BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('request_id',''))" 2>/dev/null) [[ -n "$REQUEST_ID" ]] || die "No request_id in fal.ai response: $SUBMIT_BODY" log "✓ Job submitted: request_id=$REQUEST_ID" # ── Step 5: Poll for VEED Fabric 1.0 result ────────────────────────────────── log "Step 5: Waiting for lip sync result (max ${FAL_MAX_WAIT}s)..." FAL_VIDEO_URL="" WAITED=0 while [[ $WAITED -lt $FAL_MAX_WAIT ]]; do sleep "$FAL_POLL_INTERVAL" WAITED=$((WAITED + FAL_POLL_INTERVAL)) STATUS_RESPONSE=$(curl -s -w "\n%{http_code}" \ "https://queue.fal.run/veed/fabric-1.0/requests/${REQUEST_ID}/status" \ -H "Authorization: Key ${FAL_KEY}" 2>&1) STATUS_HTTP=$(echo "$STATUS_RESPONSE" | tail -1) STATUS_BODY=$(echo "$STATUS_RESPONSE" | head -n -1) if [[ "$STATUS_HTTP" != "200" ]]; then log " Status poll returned HTTP $STATUS_HTTP — retrying..." continue fi JOB_STATUS=$(echo "$STATUS_BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('status',''))" 2>/dev/null || echo "") log " [${WAITED}s] Status: $JOB_STATUS" if [[ "$JOB_STATUS" == "COMPLETED" ]]; then # Fetch result RESULT_RESPONSE=$(curl -s -w "\n%{http_code}" \ "https://queue.fal.run/veed/fabric-1.0/requests/${REQUEST_ID}" \ -H "Authorization: Key ${FAL_KEY}" 2>&1) RESULT_HTTP=$(echo "$RESULT_RESPONSE" | tail -1) RESULT_BODY=$(echo "$RESULT_RESPONSE" | head -n -1) if [[ "$RESULT_HTTP" != "200" ]]; then die "fal.ai result fetch failed (HTTP $RESULT_HTTP): $RESULT_BODY" fi FAL_VIDEO_URL=$(echo "$RESULT_BODY" | python3 -c " import json,sys d=json.load(sys.stdin) # Output schema: {\"video\": {\"url\": \"...\"}} video = d.get('video', {}) print(video.get('url', '')) " 2>/dev/null) [[ -n "$FAL_VIDEO_URL" ]] || die "No video URL in fal.ai result: $RESULT_BODY" log "✓ Lip sync video ready: $FAL_VIDEO_URL" break elif [[ "$JOB_STATUS" == "FAILED" || "$JOB_STATUS" == "ERROR" ]]; then ERROR_MSG=$(echo "$STATUS_BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('error', d.get('detail', 'unknown')))" 2>/dev/null || echo "$STATUS_BODY") die "fal.ai job failed: $ERROR_MSG" fi # IN_QUEUE or IN_PROGRESS — keep polling done [[ -n "$FAL_VIDEO_URL" ]] || die "Timeout: fal.ai did not complete within ${FAL_MAX_WAIT}s" # ── Step 6: Download lip sync video ────────────────────────────────────────── log "Step 6: Downloading lip sync video..." RAW_VIDEO="$TMP_DIR/lipsync_raw.mp4" DL_HTTP=$(curl -s -L \ -o "$RAW_VIDEO" \ -w "%{http_code}" \ "$FAL_VIDEO_URL" 2>&1) [[ "$DL_HTTP" == "200" ]] || die "Video download failed (HTTP $DL_HTTP)" RAW_SIZE=$(stat -c%s "$RAW_VIDEO" 2>/dev/null || stat -f%z "$RAW_VIDEO") [[ "$RAW_SIZE" -gt 10240 ]] || die "Downloaded video too small (${RAW_SIZE} bytes)" log "✓ Downloaded: $RAW_VIDEO (${RAW_SIZE} bytes)" # ── Step 7: Convert to square 1:1 format for Telegram video note ───────────── log "Step 7: Converting to ${VIDEO_SIZE}x${VIDEO_SIZE} square for Telegram..." VIDEO_FILE="$TMP_DIR/video_note.mp4" "$FFMPEG_BIN" -y \ -i "$RAW_VIDEO" \ -vf "scale=${VIDEO_SIZE}:${VIDEO_SIZE}:force_original_aspect_ratio=increase,crop=${VIDEO_SIZE}:${VIDEO_SIZE}" \ -c:v libx264 -preset fast -crf 23 \ -c:a aac -b:a 64k \ -t "$DURATION_INT" \ -movflags +faststart \ "$VIDEO_FILE" 2>/dev/null [[ -f "$VIDEO_FILE" ]] || die "FFmpeg square conversion failed" VIDEO_SIZE_BYTES=$(stat -c%s "$VIDEO_FILE" 2>/dev/null || stat -f%z "$VIDEO_FILE") log "✓ Final video: $VIDEO_FILE (${VIDEO_SIZE_BYTES} bytes, ${DURATION_INT}s)" # Telegram video note limit: 50MB MAX_VIDEO_BYTES=$((50 * 1024 * 1024)) [[ "$VIDEO_SIZE_BYTES" -lt "$MAX_VIDEO_BYTES" ]] || die "Video too large for Telegram (${VIDEO_SIZE_BYTES} > 50MB)" # ── Step 8: Send as Telegram video note (кружочек) ──────────────────────────── log "Step 8: Sending video note to Telegram (chat=$CHAT_ID)..." RESPONSE=$(curl -s \ -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendVideoNote" \ -F "chat_id=${CHAT_ID}" \ -F "video_note=@${VIDEO_FILE}" \ -F "length=${VIDEO_SIZE}" \ -F "duration=${DURATION_INT}" 2>&1) SUCCESS=$(echo "$RESPONSE" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('ok', False))" 2>/dev/null || echo "false") if [[ "$SUCCESS" == "True" ]]; then MSG_ID=$(echo "$RESPONSE" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result']['message_id'])" 2>/dev/null || echo "?") log "✅ Video note sent! Message ID: $MSG_ID" echo "$MSG_ID" else TG_ERR=$(echo "$RESPONSE" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('description','unknown error'))" 2>/dev/null || echo "$RESPONSE") die "Telegram API error: $TG_ERR" fi