Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from bundle
Generate videos, transfer motion, create speaking avatars, and optionally deliver finished media to Telegram via fal.ai. Supports Kling O3/v3 Pro/Turbo, Seedance 1.5 Pro, HeyGen Avatar, and Seedream v5/v4.5.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/fal_video_toolkit.py
1#!/usr/bin/env python32"""Unified fal.ai video generation CLI.34Supports image-to-video, motion control, speaking avatar, and image editing.5"""6from __future__ import annotations78import argparse9import json10import os11import subprocess12from pathlib import Path13from typing import Any141516MODELS = {17"kling-o3": "fal-ai/kling-video/o3/standard/image-to-video",18"kling-v3-pro": "fal-ai/kling-video/v3/pro/image-to-video",19"kling-turbo": "fal-ai/kling-video/v2.5-turbo/pro/image-to-video",20"kling-motion": "fal-ai/kling-video/v3/pro/motion-control",21"kling-motion-std": "fal-ai/kling-video/v3/standard/motion-control",22"seedance": "fal-ai/bytedance/seedance/v1.5/pro/image-to-video",23"heygen": "fal-ai/heygen/avatar4/image-to-video",24"seedream-v5": "fal-ai/bytedance/seedream/v5/lite/edit",25"seedream-v4": "fal-ai/bytedance/seedream/v4.5/edit",26"grok-text": "xai/grok-imagine-video/text-to-video",27"grok-image": "xai/grok-imagine-video/image-to-video",28"grok-edit": "xai/grok-imagine-video/edit-video",29}3031MOTION_MODELS = {"kling-motion", "kling-motion-std"}32IMAGE_EDIT_MODELS = {"seedream-v5", "seedream-v4"}33AVATAR_MODELS = {"heygen"}34STRING_DURATION_MODELS = {"seedance"}353637def _load_api_key(args: argparse.Namespace) -> str:38if args.api_key:39return args.api_key.strip()40if args.api_key_file:41return Path(args.api_key_file).read_text(encoding="utf-8").strip()42for var in ("FAL_KEY", "FAL_API_KEY", "FAL_AI_KEY"):43if value := os.getenv(var):44return value.strip()45if (key_id := os.getenv("FAL_KEY_ID")) and (key_secret := os.getenv("FAL_KEY_SECRET")):46return f"{key_id.strip()}:{key_secret.strip()}"47raise SystemExit(48"Missing fal.ai credentials. Pass --api-key / --api-key-file or set FAL_KEY / FAL_AI_KEY."49)505152def _resolve_path(path_str: str) -> Path:53path = Path(path_str).expanduser()54if not path.is_absolute():55path = Path.cwd() / path56path.parent.mkdir(parents=True, exist_ok=True)57return path585960def _print_logs(update: Any) -> None:61status = getattr(update, "status", None)62logs = getattr(update, "logs", None) or []63if status:64print(f"STATUS: {status}")65for log in logs:66message = log.get("message") if isinstance(log, dict) else str(log)67if message:68print(message)697071def _is_motion_endpoint(endpoint: str) -> bool:72return endpoint.endswith("/motion-control")737475def _is_image_edit_endpoint(endpoint: str) -> bool:76return endpoint.endswith("/edit") and "seedream" in endpoint777879def _is_avatar_endpoint(endpoint: str) -> bool:80return "heygen/avatar4/image-to-video" in endpoint818283def _is_text_to_video_endpoint(endpoint: str) -> bool:84return endpoint.endswith("/text-to-video")858687def _is_video_edit_endpoint(endpoint: str) -> bool:88return endpoint.endswith("/edit-video")899091def _is_image_to_video_endpoint(endpoint: str) -> bool:92return endpoint.endswith("/image-to-video") and not _is_avatar_endpoint(endpoint)939495def _supports_audio_flag(endpoint: str) -> bool:96return "kling-video" in endpoint or "seedance" in endpoint979899def _supports_negative_prompt(endpoint: str) -> bool:100return "kling-video" in endpoint and endpoint.endswith("/image-to-video")101102103def _supports_end_image(endpoint: str) -> bool:104return (105("kling-video" in endpoint and endpoint.endswith("/image-to-video"))106or "seedance" in endpoint107)108109110def _supports_safety_checker(endpoint: str) -> bool:111return ("kling-video" in endpoint and endpoint.endswith("/image-to-video")) or (112"seedream" in endpoint and endpoint.endswith("/edit")113)114115116def _supports_resolution(endpoint: str) -> bool:117return (118"seedance" in endpoint119or _is_avatar_endpoint(endpoint)120or endpoint.startswith("xai/grok-imagine-video/")121)122123124def _supports_aspect_ratio(endpoint: str) -> bool:125return (126"seedance" in endpoint127or _is_avatar_endpoint(endpoint)128or endpoint in {129"xai/grok-imagine-video/text-to-video",130"xai/grok-imagine-video/image-to-video",131}132)133134135def _extract_frame(video_path: Path, frame_time: str, out_path: Path) -> Path:136out_path.parent.mkdir(parents=True, exist_ok=True)137cmd = [138"ffmpeg",139"-y",140"-ss",141frame_time,142"-i",143str(video_path),144"-frames:v",145"1",146str(out_path),147]148try:149subprocess.run(cmd, check=True, capture_output=True, text=True)150except FileNotFoundError as exc:151raise SystemExit("ffmpeg is required to extract a frame from video.") from exc152except subprocess.CalledProcessError as exc:153stderr = (exc.stderr or "").strip()154raise SystemExit(155f"Failed to extract frame from {video_path}: {stderr or exc}"156) from exc157return out_path158159160def _send_to_telegram(161media_path: Path,162*,163target: str,164thread_id: str | None,165reply_to: str | None,166message: str | None,167) -> None:168cmd = [169"openclaw",170"message",171"send",172"--channel",173"telegram",174"--target",175target,176"--media",177str(media_path),178]179if message:180cmd.extend(["--message", message])181if thread_id:182cmd.extend(["--thread-id", thread_id])183if reply_to:184cmd.extend(["--reply-to", reply_to])185186print(f"Sending to Telegram target {target}...")187try:188result = subprocess.run(cmd, check=True, capture_output=True, text=True)189except FileNotFoundError as exc:190raise SystemExit("openclaw CLI is required for Telegram delivery.") from exc191except subprocess.CalledProcessError as exc:192stderr = (exc.stderr or "").strip()193stdout = (exc.stdout or "").strip()194details = stderr or stdout or str(exc)195raise SystemExit(f"Telegram send failed: {details}") from exc196197if result.stdout.strip():198print(result.stdout.strip())199if result.stderr.strip():200print(result.stderr.strip())201print("Telegram send complete.")202203204def _warn(message: str) -> None:205print(f"WARNING: {message}")206207208def main() -> None:209model_list = "\n".join(f" {k:20s} {v}" for k, v in MODELS.items())210parser = argparse.ArgumentParser(211description="fal.ai video/image generation CLI.",212epilog=f"Available model shortcuts:\n{model_list}",213formatter_class=argparse.RawDescriptionHelpFormatter,214)215parser.add_argument("--image", help="Input image path.")216parser.add_argument(217"--video",218help="Input video path for motion control or video-edit models.",219)220parser.add_argument(221"--extract-first-frame-from-video",222help="Extract a frame from this video and use it as the input image.",223)224parser.add_argument(225"--extract-frame-time",226default="00:00:00",227help="Timestamp for the extracted frame (default: 00:00:00).",228)229parser.add_argument("--prompt", default="", help="Guidance prompt or speech text (HeyGen).")230parser.add_argument(231"--model", default="kling-o3",232help=f"Model shortcut or full fal endpoint. Shortcuts: {', '.join(MODELS.keys())}",233)234parser.add_argument("--duration", default="10", help="Video duration in seconds.")235parser.add_argument("--orientation", choices=["image", "video"], default="video",236help="Motion control orientation.")237parser.add_argument("--resolution", help="Resolution for Seedance/HeyGen (480p/720p/1080p).")238parser.add_argument("--aspect-ratio", help="Aspect ratio (16:9, 9:16, 1:1, etc).")239parser.add_argument("--no-audio", action="store_true", help="Disable audio generation.")240parser.add_argument("--no-safety", action="store_true", help="Disable client safety checker.")241parser.add_argument("--negative-prompt", default="blur, distortion, low quality, artifacts, glitch, deformed hands, watermark",242help="Negative prompt for Kling models.")243parser.add_argument("--talking-style", choices=["stable", "expressive"], default="expressive",244help="HeyGen talking style.")245parser.add_argument("--image-size", help="Output image size for Seedream (e.g. 2048x2048).")246parser.add_argument("--end-image", help="End frame image path (Kling O3 / Seedance).")247parser.add_argument("--out", required=True, help="Output file path.")248parser.add_argument("--json-out", help="Save raw fal response JSON.")249parser.add_argument("--api-key", help="fal key value.")250parser.add_argument("--api-key-file", help="Read fal key from file.")251parser.add_argument("--telegram-target", help="Telegram chat id or @username for optional delivery.")252parser.add_argument("--telegram-thread-id", help="Optional Telegram thread/topic id.")253parser.add_argument("--telegram-reply-to", help="Optional Telegram message id to reply to.")254parser.add_argument("--telegram-message", default="", help="Optional Telegram caption/message.")255args = parser.parse_args()256257api_key = _load_api_key(args)258os.environ["FAL_KEY"] = api_key259260model_key = args.model261endpoint = MODELS.get(model_key, model_key)262263out_path = _resolve_path(args.out)264json_out_path = _resolve_path(args.json_out) if args.json_out else None265266import fal_client267from urllib.request import urlopen268269# Build payload based on model type270payload: dict[str, Any] = {}271image_url: str | None = None272image_path: Path | None = None273274if args.image:275image_path = Path(args.image).expanduser()276if not image_path.exists():277raise SystemExit(f"Image not found: {image_path}")278279if args.extract_first_frame_from_video:280source_video = Path(args.extract_first_frame_from_video).expanduser()281if not source_video.exists():282raise SystemExit(f"Frame source video not found: {source_video}")283if image_path:284_warn("--image was provided, so --extract-first-frame-from-video is ignored.")285else:286frame_path = out_path.with_suffix(".first-frame.png")287print(f"Extracting frame at {args.extract_frame_time}: {source_video} -> {frame_path}")288image_path = _extract_frame(source_video, args.extract_frame_time, frame_path)289290if _is_motion_endpoint(endpoint) or _is_image_edit_endpoint(endpoint) or _is_avatar_endpoint(endpoint) or _is_image_to_video_endpoint(endpoint):291if not image_path:292raise SystemExit(293f"This model requires an image. Pass --image or --extract-first-frame-from-video."294)295print(f"Uploading image: {image_path}")296image_url = fal_client.upload_file(str(image_path))297print(f"IMAGE_URL: {image_url}")298299if _is_motion_endpoint(endpoint):300if not args.video:301raise SystemExit("--video is required for motion control models.")302video_path = Path(args.video).expanduser()303if not video_path.exists():304raise SystemExit(f"Video not found: {video_path}")305print(f"Uploading video: {video_path}")306video_url = fal_client.upload_file(str(video_path))307print(f"VIDEO_URL: {video_url}")308payload = {309"image_url": image_url,310"video_url": video_url,311"character_orientation": args.orientation,312"keep_original_sound": not args.no_audio,313}314315elif _is_image_edit_endpoint(endpoint):316payload = {317"image_urls": [image_url],318"prompt": args.prompt.strip() or "enhance image quality",319}320if _supports_safety_checker(endpoint):321payload["enable_safety_checker"] = not args.no_safety322if args.image_size:323w, h = args.image_size.split("x")324payload["image_size"] = {"width": int(w), "height": int(h)}325326elif _is_avatar_endpoint(endpoint):327payload = {328"image_url": image_url,329"prompt": args.prompt.strip(),330"talking_style": args.talking_style,331}332if args.resolution and _supports_resolution(endpoint):333payload["resolution"] = args.resolution334if args.aspect_ratio and _supports_aspect_ratio(endpoint):335payload["aspect_ratio"] = args.aspect_ratio336337elif _is_text_to_video_endpoint(endpoint):338if not args.prompt.strip():339raise SystemExit("--prompt is required for text-to-video models.")340payload = {341"prompt": args.prompt.strip(),342"duration": int(args.duration),343}344if args.aspect_ratio and _supports_aspect_ratio(endpoint):345payload["aspect_ratio"] = args.aspect_ratio346if args.resolution and _supports_resolution(endpoint):347payload["resolution"] = args.resolution348349elif _is_video_edit_endpoint(endpoint):350if not args.video:351raise SystemExit("--video is required for video-edit models.")352if not args.prompt.strip():353raise SystemExit("--prompt is required for video-edit models.")354video_path = Path(args.video).expanduser()355if not video_path.exists():356raise SystemExit(f"Video not found: {video_path}")357print(f"Uploading video: {video_path}")358video_url = fal_client.upload_file(str(video_path))359print(f"VIDEO_URL: {video_url}")360payload = {361"prompt": args.prompt.strip(),362"video_url": video_url,363}364if args.resolution and _supports_resolution(endpoint):365payload["resolution"] = args.resolution366367else:368# Image-to-video (Kling, Seedance, Grok Image)369if not args.prompt.strip():370raise SystemExit("--prompt is required for image-to-video models.")371duration = args.duration372if model_key in STRING_DURATION_MODELS or "seedance" in endpoint:373duration = str(duration)374else:375try:376duration = int(duration)377except ValueError:378pass379380payload = {381"image_url": image_url,382"prompt": args.prompt.strip(),383"duration": duration,384}385386if _supports_safety_checker(endpoint):387payload["enable_safety_checker"] = not args.no_safety388389if model_key in STRING_DURATION_MODELS or "seedance" in endpoint:390if args.resolution and _supports_resolution(endpoint):391payload["resolution"] = args.resolution392if args.aspect_ratio and _supports_aspect_ratio(endpoint):393payload["aspect_ratio"] = args.aspect_ratio394if _supports_audio_flag(endpoint):395payload["generate_audio"] = not args.no_audio396else:397if args.resolution and _supports_resolution(endpoint):398payload["resolution"] = args.resolution399if args.aspect_ratio and _supports_aspect_ratio(endpoint):400payload["aspect_ratio"] = args.aspect_ratio401if _supports_audio_flag(endpoint):402payload["generate_audio"] = not args.no_audio403if args.negative_prompt and _supports_negative_prompt(endpoint):404payload["negative_prompt"] = args.negative_prompt405406if args.end_image and _supports_end_image(endpoint):407end_path = Path(args.end_image).expanduser()408if not end_path.exists():409raise SystemExit(f"End image not found: {end_path}")410print(f"Uploading end image: {end_path}")411payload["end_image_url"] = fal_client.upload_file(str(end_path))412413if args.no_safety and not _supports_safety_checker(endpoint):414_warn(415f"{endpoint} does not expose a client-side safety toggle in the fal.ai schema. "416"--no-safety has no effect here."417)418if args.no_audio and not _su