📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-07-01 16:02:41 +00:00
parent 8301f01888
commit c824ba9d7b
2449 changed files with 555104 additions and 9259 deletions
@@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""
Generates and edits videos using the Gemini Omni Flash model via the google-genai Interactions API.
Can automatically upload local media references using the Files API.
Supports parallel execution of multiple generations using Python standard library.
Uses the official google-genai SDK.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import os
import re
import sys
import time
import urllib.request
import urllib.error
import uuid
from google import genai
# Load local upload helper logic inline to prevent dependency issues
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from upload_file import upload_file, wait_for_active
def get_api_key(args):
"""Retrieves API key from command args or environment."""
if args.api_key:
return args.api_key
return os.environ.get("GEMINI_API_KEY")
def is_file_uri(uri):
"""Returns True if the string is a standard Gemini File URI."""
if not uri:
return False
return "files/" in uri and ("generativelanguage.googleapis.com" in uri or uri.startswith("files/"))
def normalize_file_uri(uri):
"""Normalizes any File API URI/reference to the standard https://generativelanguage.googleapis.com/files/{id} format."""
if not uri:
return None
match = re.search(r'files/([a-zA-Z0-9]+)', uri)
if match:
file_id = match.group(1)
return f"https://generativelanguage.googleapis.com/files/{file_id}"
return uri
def slugify(text):
"""Converts a text prompt into a safe, descriptive filename slug."""
text = text.lower()
text = re.sub(r'[^a-z0-9]+', '_', text)
return text.strip('_')[:50]
def parse_and_validate_duration(value):
"""Parses and formats a duration integer between 3 and 10 with optional 's' suffix."""
if value is None:
return None
if isinstance(value, (int, float)):
val = float(value)
else:
clean_value = str(value).strip().lower()
if clean_value in ('none', ''):
return None
if clean_value.endswith('s'):
clean_value = clean_value[:-1]
try:
val = float(clean_value)
except ValueError:
raise ValueError(f"Invalid duration value: '{value}'. Must be an integer (e.g., 5, 10).")
if not val.is_integer():
raise ValueError(f"Duration must be an integer, not a float (e.g., got {value}).")
val_int = int(val)
if val_int < 3 or val_int > 10:
raise ValueError(f"Duration must be between 3 (inclusive) and 10 (inclusive) seconds. Got {val_int}.")
return f"{val_int}s"
def argparse_duration_type(value):
"""argparse type converter for validating duration."""
if value is None or str(value).strip().lower() in ('none', ''):
return None
try:
return parse_and_validate_duration(value)
except ValueError as e:
raise argparse.ArgumentTypeError(str(e))
def resolve_or_upload_asset(asset_path, mime_type, api_key, strip_audio=False):
"""
If asset_path is a File API URI, returns it directly (normalized).
If it is a local file path, uploads it and returns its File API URI (normalized).
"""
if not asset_path:
return None, None
if is_file_uri(asset_path):
normalized = normalize_file_uri(asset_path)
print(f"Using existing File URI: {normalized}")
if strip_audio:
print("Warning: --strip-audio was specified but the video input is an existing remote File URI. "
"Audio cannot be stripped from remote files automatically.")
return normalized, mime_type
if os.path.exists(asset_path):
upload_path = asset_path
temp_stripped_path = None
if strip_audio:
print(f"Detected local asset path '{asset_path}'. Stripping audio before upload...")
# Check if ffmpeg is available
import subprocess
try:
subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
except (subprocess.SubprocessError, FileNotFoundError):
raise RuntimeError(
"Error: ffmpeg is not installed or not found in system PATH. "
"ffmpeg is required to strip audio from local videos."
)
try:
os.makedirs("media", exist_ok=True)
base_name = os.path.basename(asset_path)
name, ext = os.path.splitext(base_name)
temp_stripped_path = os.path.join("media", f"temp_stripped_{name}_{uuid.uuid4().hex}{ext}")
# Fast stream-copy audio stripping
cmd = ["ffmpeg", "-y", "-i", asset_path, "-c:v", "copy", "-an", temp_stripped_path]
subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
print(f"Successfully stripped audio. Temporary video file created at: {temp_stripped_path}")
upload_path = temp_stripped_path
except Exception as e:
print(f"Error stripping audio using ffmpeg: {e}", file=sys.stderr)
print("Falling back to uploading the original video with audio.", file=sys.stderr)
print(f"Uploading asset '{upload_path}'...")
file_meta = upload_file(upload_path, api_key=api_key)
file_name = file_meta.get("name")
# Wait for file to become active
file_meta = wait_for_active(file_name, api_key)
normalized = normalize_file_uri(file_meta.get("uri"))
# Clean up temporary stripped file if we created one
if temp_stripped_path and os.path.exists(temp_stripped_path):
try:
os.remove(temp_stripped_path)
print(f"Cleaned up temporary video file: {temp_stripped_path}")
except Exception as e:
print(f"Warning: Failed to remove temporary file {temp_stripped_path}: {e}", file=sys.stderr)
# Handle both mimeType and mime_type key formats returned from upload_file
returned_mime = file_meta.get("mimeType") or file_meta.get("mime_type")
return normalized, returned_mime
else:
raise FileNotFoundError(f"Asset path '{asset_path}' is neither a valid File API URI nor a local file path.")
def download_video_file(file_uri, output_path, api_key):
"""Downloads generated video file from URI using alt=media standard in a memory-safe, chunked manner."""
separator = "&" if "?" in file_uri else "?"
download_url = f"{file_uri}{separator}alt=media"
print(f"Downloading video from {file_uri} to {output_path} in chunked mode...")
req = urllib.request.Request(download_url)
req.add_header("x-goog-api-key", api_key)
try:
with urllib.request.urlopen(req, timeout=480) as resp:
parent_dir = os.path.dirname(output_path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
with open(output_path, "wb") as f:
while True:
chunk = resp.read(8192)
if not chunk:
break
f.write(chunk)
print(f"Video successfully saved to: {output_path}")
except urllib.error.HTTPError as e:
raise RuntimeError(f"Error downloading video file: {e.code} - {e.read().decode()}")
def generate_video(prompt, api_key, model="gemini-omni-flash-preview", aspect_ratio="16:9", duration=None, image_path=None, video_path=None, output_path="output.mp4", strip_audio=False, previous_interaction_id=None):
"""Creates an interaction with the video model and downloads the resulting video using the official google-genai SDK."""
duration = parse_and_validate_duration(duration)
input_parts = []
# 1. Resolve and add image inputs (reference/start/end frames)
if image_path:
if isinstance(image_path, list):
for path in image_path:
img_uri, img_mime = resolve_or_upload_asset(path, "image/png", api_key)
input_parts.append({
"type": "image",
"uri": img_uri,
"mime_type": img_mime
})
else:
img_uri, img_mime = resolve_or_upload_asset(image_path, "image/png", api_key)
input_parts.append({
"type": "image",
"uri": img_uri,
"mime_type": img_mime
})
# 2. Resolve and add video inputs (for edits or extensions)
if video_path:
if isinstance(video_path, list):
for path in video_path:
vid_uri, vid_mime = resolve_or_upload_asset(path, "video/mp4", api_key, strip_audio=strip_audio)
input_parts.append({
"type": "video",
"uri": vid_uri,
"mime_type": vid_mime
})
else:
vid_uri, vid_mime = resolve_or_upload_asset(video_path, "video/mp4", api_key, strip_audio=strip_audio)
input_parts.append({
"type": "video",
"uri": vid_uri,
"mime_type": vid_mime
})
# 3. Add text prompt
input_parts.append({
"type": "text",
"text": prompt
})
# Construct the config
video_config = {
"type": "video",
"aspect_ratio": aspect_ratio,
"delivery": "uri"
}
if duration:
video_config["duration"] = duration
print(f"\nSending generation request using official google-genai SDK and model '{model}'...")
print(f"Prompt: '{prompt}' | Aspect Ratio: {aspect_ratio} | Duration: {duration}")
# Initialize the client and call interactions.create
client = genai.Client(api_key=api_key)
try:
interaction = client.interactions.create(
model=model,
input=input_parts,
response_format=video_config,
previous_interaction_id=previous_interaction_id
)
except Exception as e:
raise RuntimeError(f"Error generating video via SDK: {e}")
print(f"Generation complete for '{prompt}'! Processing response...")
interaction_id = interaction.id
if interaction_id:
print(f"Interaction ID: {interaction_id}")
output_video = interaction.output_video
if not output_video or not output_video.uri:
err_msg = f"No video content found in response for '{prompt}'."
if video_path:
err_msg += (
"\nWARNING: IMPORTANT REGIONAL RESTRICTION: Uploading videos to use for video edits is "
"not available in the EEA, Switzerland, United Kingdom, and some US states."
)
raise RuntimeError(f"{err_msg}\nResponse output_video field: {output_video}")
video_uri = output_video.uri
print(f"Generated video URI for '{prompt}': {video_uri}")
# Download the final video
download_video_file(video_uri, output_path, api_key)
def run_job(job, api_key):
"""Runs a single generation job inside a thread pool, catching exceptions."""
prompt = job.get("prompt")
if not prompt:
print("Warning: Skipping job with empty prompt.", file=sys.stderr)
return {"job": job, "status": "SKIPPED", "error": "Empty prompt"}
aspect_ratio = job.get("aspect_ratio", "16:9")
duration = job.get("duration")
image_path = job.get("image")
video_path = job.get("video")
output_path = job.get("output")
model = job.get("model", "gemini-omni-flash-preview")
strip_audio = job.get("strip_audio", False)
previous_interaction_id = job.get("previous_interaction_id")
if not output_path:
output_path = f"media/output_{slugify(prompt)}.mp4"
print(f"[Parallel] Dispatching: '{prompt}' (Output: {output_path})")
try:
generate_video(
prompt=prompt,
api_key=api_key,
model=model,
aspect_ratio=aspect_ratio,
duration=duration,
image_path=image_path,
video_path=video_path,
output_path=output_path,
strip_audio=strip_audio,
previous_interaction_id=previous_interaction_id
)
return {"job": job, "status": "SUCCESS", "output_path": output_path}
except Exception as e:
print(f"[Parallel] Failed: '{prompt}' - Error: {e}", file=sys.stderr)
return {"job": job, "status": "FAILED", "error": str(e)}
def main():
parser = argparse.ArgumentParser(description="Generate and edit videos using Gemini Omni Flash model via google-genai SDK (supports parallel batch execution).")
parser.add_argument("prompt", nargs="?", help="Text prompt / instruction for a single video generation")
parser.add_argument("--image", action="append", help="Optional local image path or File API URI for referencing / image-to-video (can be specified multiple times)")
parser.add_argument("--video", action="append", help="Optional local video path or File API URI for editing / extending (can be specified multiple times)")
parser.add_argument("--aspect-ratio", default="16:9", choices=["16:9", "9:16"], help="Aspect ratio (default: 16:9)")
parser.add_argument("--duration", type=argparse_duration_type, default=None, help="Video duration as an integer between 3 and 10 seconds (e.g., 5, 10). Default: None (API/Model decides, typically 10s or matches source)")
parser.add_argument("--model", default="gemini-omni-flash-preview", help="Gemini Omni Flash video model ID (default: gemini-omni-flash-preview)")
parser.add_argument("--output", help="Local output file path for single generation (default: media/output.mp4)")
parser.add_argument("--strip-audio", "-a", action="store_true", help="Completely strip/disable audio stream from the input video(s) before uploading so Gemini Omni Flash can regenerate new audio from scratch")
parser.add_argument("--previous-interaction-id", help="Optional Interaction ID of a previous generation for turn-by-turn editing")
parser.add_argument("--api-key", help="Gemini API Key (overrides env)")
# Parallel batch configuration options
parser.add_argument("--batch", help="Path to a JSON file containing an array of generation jobs")
parser.add_argument("--prompts-file", help="Path to a text file containing one prompt per line to run in parallel")
parser.add_argument("--concurrency", type=int, default=3, help="Maximum number of concurrent executions (default: 3)")
args = parser.parse_args()
api_key = get_api_key(args)
if not api_key:
print("Error: API key is not set. Use --api-key or set GEMINI_API_KEY environment variable.", file=sys.stderr)
sys.exit(1)
# 1. Handle Batch JSON execution
if args.batch:
if not os.path.exists(args.batch):
print(f"Error: Batch JSON file '{args.batch}' not found.", file=sys.stderr)
sys.exit(1)
try:
with open(args.batch, "r", encoding="utf-8") as f:
jobs = json.load(f)
if not isinstance(jobs, list):
print("Error: Batch JSON file must contain a list/array of job objects.", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error parsing Batch JSON: {e}", file=sys.stderr)
sys.exit(1)
print(f"Loaded {len(jobs)} jobs from batch JSON. Running with concurrency={args.concurrency}...")
# 2. Handle Prompts File execution
elif args.prompts_file:
if not os.path.exists(args.prompts_file):
print(f"Error: Prompts file '{args.prompts_file}' not found.", file=sys.stderr)
sys.exit(1)
jobs = []
with open(args.prompts_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
jobs.append({
"prompt": line,
"aspect_ratio": args.aspect_ratio,
"duration": args.duration,
"image": args.image,
"video": args.video,
"model": args.model,
"strip_audio": args.strip_audio,
"previous_interaction_id": args.previous_interaction_id
})
print(f"Loaded {len(jobs)} prompts from text file. Running with concurrency={args.concurrency}...")
# 3. Handle standard single prompt execution
else:
if not args.prompt:
parser.print_help()
sys.exit(1)
output_path = args.output if args.output else "media/output.mp4"
try:
generate_video(
prompt=args.prompt,
api_key=api_key,
model=args.model,
aspect_ratio=args.aspect_ratio,
duration=args.duration,
image_path=args.image,
video_path=args.video,
output_path=output_path,
strip_audio=args.strip_audio,
previous_interaction_id=args.previous_interaction_id
)
sys.exit(0)
except Exception as e:
print(f"Error: Generation failed: {e}", file=sys.stderr)
sys.exit(1)
# Parallel Execution Loop
if not jobs:
print("Warning: No valid jobs found to execute.")
sys.exit(0)
results = []
with ThreadPoolExecutor(max_workers=args.concurrency) as executor:
futures = {executor.submit(run_job, job, api_key): job for job in jobs}
for future in as_completed(futures):
results.append(future.result())
# Print Batch Results Summary
print("\n" + "="*50)
print("BATCH PARALLEL EXECUTION SUMMARY")
print("="*50)
success_count = sum(1 for r in results if r["status"] == "SUCCESS")
failed_count = sum(1 for r in results if r["status"] == "FAILED")
skipped_count = sum(1 for r in results if r["status"] == "SKIPPED")
print(f"Total: {len(results)} | Success: {success_count} | Failed: {failed_count} | Skipped: {skipped_count}\n")
for r in results:
status_str = r["status"]
prompt = r["job"].get("prompt")
if r["status"] == "SUCCESS":
print(f" [{status_str}] '{prompt}' -> {r['output_path']}")
else:
print(f" [{status_str}] '{prompt}' -> Error: {r.get('error')}")
print("="*50)
if failed_count > 0:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
import argparse
import json
import os
import subprocess
import sys
def format_size(size_bytes):
"""Formats file size in bytes to a human-readable string."""
try:
size_bytes = int(size_bytes)
except (ValueError, TypeError):
return "Unknown size"
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} TB"
def parse_fps(fps_str):
"""Parses fractional frame rates like '30/1' or '24000/1001' into floats."""
if not fps_str:
return "Unknown"
if "/" in fps_str:
try:
num, den = map(float, fps_str.split("/"))
if den != 0:
val = num / den
if val.is_integer():
return f"{int(val)} fps"
return f"{val:.2f} fps"
except (ValueError, ZeroDivisionError):
pass
try:
val = float(fps_str)
if val.is_integer():
return f"{int(val)} fps"
return f"{val:.2f} fps"
except ValueError:
return fps_str
def inspect_video(file_path, raw=False):
"""Runs ffprobe on the video file and returns parsed metadata dictionary."""
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
# Check if ffprobe is available
try:
subprocess.run(["ffprobe", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
except (subprocess.SubprocessError, FileNotFoundError):
raise RuntimeError("ffprobe is not installed or not found in system PATH.")
cmd = [
"ffprobe",
"-v", "error",
"-show_format",
"-show_streams",
"-of", "json",
file_path
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
data = json.loads(result.stdout)
if raw:
return data
# Extract format level details
fmt = data.get("format", {})
duration = fmt.get("duration")
size_bytes = fmt.get("size")
bitrate = fmt.get("bit_rate")
# Format files size
size_str = format_size(size_bytes) if size_bytes else "Unknown"
# Parse duration
try:
duration_val = float(duration) if duration else 0.0
duration_str = f"{duration_val:.2f}s"
except ValueError:
duration_str = "Unknown"
duration_val = None
# Parse bitrate
try:
bitrate_kbps = f"{int(float(bitrate) / 1000)} kbps" if bitrate else "Unknown"
except ValueError:
bitrate_kbps = "Unknown"
video_streams = [s for s in data.get("streams", []) if s.get("codec_type") == "video"]
audio_streams = [s for s in data.get("streams", []) if s.get("codec_type") == "audio"]
has_video = len(video_streams) > 0
has_audio = len(audio_streams) > 0
video_info = {}
if has_video:
v = video_streams[0]
width = v.get("width")
height = v.get("height")
codec = v.get("codec_name", "Unknown").upper()
r_fps = parse_fps(v.get("r_frame_rate"))
avg_fps = parse_fps(v.get("avg_frame_rate"))
# Prefer r_frame_rate but fallback to avg
fps = r_fps if r_fps != "0 fps" and r_fps != "Unknown" else avg_fps
video_info = {
"resolution": f"{width}x{height}" if width and height else "Unknown",
"width": width,
"height": height,
"fps": fps,
"codec": codec,
"duration": v.get("duration")
}
audio_info = {}
if has_audio:
a = audio_streams[0]
codec = a.get("codec_name", "Unknown").upper()
channels = a.get("channels", "Unknown")
sample_rate = a.get("sample_rate")
sample_rate_khz = f"{float(sample_rate)/1000:.1f} kHz" if sample_rate else "Unknown"
audio_info = {
"codec": codec,
"channels": channels,
"sample_rate": sample_rate_khz
}
return {
"file_name": os.path.basename(file_path),
"file_size": size_str,
"size_bytes": size_bytes,
"duration": duration_str,
"duration_seconds": duration_val,
"bitrate": bitrate_kbps,
"has_video": has_video,
"video": video_info,
"has_audio": has_audio,
"audio": audio_info
}
def print_terminal_report(info):
"""Prints an aligned terminal report."""
print(f"\nVideo Inspection Report: {info['file_name']}")
print("=" * 50)
print(f"File Size : {info['file_size']}")
print(f"Duration : {info['duration']}")
print(f"Bitrate : {info['bitrate']}")
print("\nVideo Stream Details:")
if info["has_video"]:
v = info["video"]
print(f" * Resolution : {v['resolution']}")
print(f" * Frame Rate : {v['fps']}")
print(f" * Codec : {v['codec']}")
else:
print(" * No Video Stream Found.")
print("\nAudio Stream Details:")
if info["has_audio"]:
a = info["audio"]
print(" * Status : Audio Present")
print(f" * Codec : {a['codec']}")
print(f" * Channels : {a['channels']}")
print(f" * Sample Rate: {a['sample_rate']}")
else:
print(" * Status : No Audio Stream Present")
print()
def main():
parser = argparse.ArgumentParser(description="Inspect video details (duration, frame rate, resolution, audio presence) using ffprobe.")
parser.add_argument("file", help="Path to the video file to inspect")
parser.add_argument("--json", action="store_true", help="Output parsed summary in JSON format")
parser.add_argument("--raw", action="store_true", help="Output raw unmodified ffprobe JSON data")
args = parser.parse_args()
try:
if args.raw:
info = inspect_video(args.file, raw=True)
print(json.dumps(info, indent=2))
else:
info = inspect_video(args.file, raw=False)
if args.json:
print(json.dumps(info, indent=2))
else:
print_terminal_report(info)
except Exception as e:
print(f"Error inspecting video: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
import argparse
import os
import subprocess
import sys
from inspect_video import inspect_video, format_size
def parse_timecode(time_str, total_duration=None):
"""Parses a time string (seconds, MM:SS, HH:MM:SS, or 'last') into float seconds."""
if not time_str:
return 0.0
time_str = time_str.strip().lower()
if time_str == "last":
if total_duration is None:
raise ValueError("Total duration is required to calculate 'last' starting point.")
target_dur = 10.0
if total_duration <= target_dur:
return 0.0
return total_duration - target_dur
if ":" in time_str:
parts = time_str.split(":")
if len(parts) == 2: # MM:SS
m, s = map(float, parts)
return m * 60.0 + s
elif len(parts) == 3: # HH:MM:SS
h, m, s = map(float, parts)
return h * 3600.0 + m * 60.0 + s
else:
raise ValueError(f"Invalid timecode format: '{time_str}'. Use HH:MM:SS or MM:SS.")
try:
return float(time_str)
except ValueError:
raise ValueError(f"Invalid timecode: '{time_str}'. Must be float seconds, HH:MM:SS, or 'last'.")
def prep_video(input_path, output_path, start_time_str=None, duration=10, fps=None, resolution=None, strip_audio=False):
"""Preps a video file by trimming, optionally re-encoding to target fps and resolution."""
if not os.path.exists(input_path):
raise FileNotFoundError(f"Input file not found: {input_path}")
# Check if ffmpeg is available
try:
subprocess.run(["ffmpeg", "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
except (subprocess.SubprocessError, FileNotFoundError):
raise RuntimeError("ffmpeg is not installed or not found in system PATH.")
# Inspect input video first
print(f"Analyzing source video: {os.path.basename(input_path)}...")
source_info = inspect_video(input_path)
total_duration = source_info.get("duration_seconds", 0.0)
# Resolve start time
if start_time_str is None:
if total_duration and total_duration > 10.0 and sys.stdin.isatty():
print(f"\nThe input video is longer than 10s ({total_duration:.2f}s).")
print("Please choose a 10s segment to trim:")
print(" 1) First 10 seconds [default]")
print(" 2) Last 10 seconds")
print(" 3) Custom starting timecode (e.g., MM:SS, HH:MM:SS, or seconds)")
try:
choice = input("Your choice [1/2/3, default 1]: ").strip()
if choice == "2":
start_time_str = "last"
elif choice == "3":
custom_start = input("Enter starting timecode (e.g., 00:03 or 15): ").strip()
start_time_str = custom_start if custom_start else "0"
else:
start_time_str = "0"
except (KeyboardInterrupt, EOFError):
print("\nNo input received. Defaulting to first 10 seconds.")
start_time_str = "0"
else:
start_time_str = "0"
try:
start_seconds = parse_timecode(start_time_str, total_duration)
except Exception as e:
raise ValueError(f"Timecode parsing failed: {e}")
if start_seconds < 0 or (total_duration and start_seconds >= total_duration):
raise ValueError(f"Start time {start_seconds}s is out of bounds for video of length {total_duration}s.")
# Construct output path if not specified
if not output_path:
os.makedirs("media", exist_ok=True)
base_name = os.path.basename(input_path)
name, ext = os.path.splitext(base_name)
output_path = os.path.join("media", f"prepped_{name}.mp4")
# Check if the source video file is large (>25MB)
size_bytes_str = source_info.get("size_bytes")
is_large = False
try:
if size_bytes_str and int(size_bytes_str) > 25 * 1024 * 1024:
is_large = True
except (ValueError, TypeError):
pass
# Target resolution parsing
scale_filter = None
orig_width = None
orig_height = None
if "video" in source_info:
try:
orig_width = int(source_info["video"].get("width"))
orig_height = int(source_info["video"].get("height"))
except (ValueError, TypeError):
pass
if resolution:
try:
target_w, target_h = map(int, resolution.lower().split("x"))
if orig_width and orig_height:
# Scale to fit target_w and target_h while preserving aspect ratio
scale_factor = min(target_w / orig_width, target_h / orig_height)
width = int(orig_width * scale_factor)
height = int(orig_height * scale_factor)
else:
width, height = target_w, target_h
# Ensure divisible by 2 for standard decoders/encoders
width = (width // 2) * 2
height = (height // 2) * 2
scale_filter = f"scale={width}:{height}"
resolution = f"{width}x{height}"
except ValueError:
raise ValueError(f"Invalid resolution: '{resolution}'. Format must be WIDTHxHEIGHT (e.g. 1280x720).")
elif is_large:
if orig_width and orig_height:
# Scale down large videos proportionally (max 1280x720 for landscape, 720x1280 for portrait)
if orig_width >= orig_height:
max_w, max_h = 1280, 720
else:
max_w, max_h = 720, 1280
scale_factor = min(max_w / orig_width, max_h / orig_height)
if scale_factor < 1.0:
width = int(orig_width * scale_factor)
height = int(orig_height * scale_factor)
else:
width, height = orig_width, orig_height
else:
width, height = 1280, 720
# Ensure divisible by 2
width = (width // 2) * 2
height = (height // 2) * 2
resolution = f"{width}x{height}"
print(f"\nRecommendation: Source video is very large ({source_info.get('file_size')}).")
print(" Automatically scaling to optimize upload times for Gemini Omni Flash.")
scale_filter = f"scale={width}:{height}"
fps_spec = f"{fps} fps" if fps else "Original frame rate"
print(f"\nPreparing Video Processing:")
print(f" * Source Duration: {total_duration:.2f}s")
print(f" * Trim Range : Start at {start_seconds:.2f}s | Length {duration:.2f}s")
if resolution:
print(f" * Encoding Specs : {width}x{height} @ {fps_spec}")
else:
print(f" * Encoding Specs : Original Resolution @ {fps_spec}")
print(f" * Target Path : {output_path}")
print("=" * 50)
# ffmpeg command construction
cmd = [
"ffmpeg",
"-y", # Overwrite output
"-ss", str(start_seconds), # Seek start
"-i", input_path, # Input file
"-t", str(duration), # Duration to copy
]
if scale_filter:
cmd.extend(["-vf", scale_filter])
cmd.extend([
"-c:v", "libx264", # Standard H264 video codec
"-pix_fmt", "yuv420p", # Standard pixel format for web/Gemini compatibility
])
if fps:
cmd.extend(["-r", str(fps)]) # Output frame rate if requested
if strip_audio or not source_info.get("has_audio", False):
if not source_info.get("has_audio", False) and not strip_audio:
print("No audio stream detected in source video. Disabling audio output.")
else:
print("Stripping audio stream from video as requested.")
cmd.append("-an") # Disable audio streams completely
else:
cmd.extend([
"-c:a", "aac", # Convert audio to standard AAC
"-b:a", "128k", # Standard audio bitrate
"-ac", "2", # Convert to stereo
])
cmd.append(output_path)
print("Running ffmpeg encoding...")
process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if process.returncode != 0:
print("Error: ffmpeg failed. Stderr output follows:", file=sys.stderr)
print(process.stderr, file=sys.stderr)
raise RuntimeError("ffmpeg execution failed.")
print("Video preparation completed successfully!")
print("=" * 50)
# Call inspection tool on output to print clean specs
output_info = inspect_video(output_path)
return output_info
def main():
parser = argparse.ArgumentParser(description="Prep videos for editing (trimming, re-encoding to target fps and resolution).")
parser.add_argument("file", help="Path to the source video file to prep")
parser.add_argument("--start", "-s", default=None, help="Start timecode (seconds, MM:SS, HH:MM:SS, or 'last' for last 10s). Default: 0 (or prompted if > 10s)")
parser.add_argument("--duration", "-d", type=int, default=10, help="Duration of trimmed segment in seconds. Default: 10")
parser.add_argument("--fps", "-r", type=int, default=None, help="Target frame rate. Default: None (keep original frame rate)")
parser.add_argument("--resolution", "-g", default=None, help="Target resolution (e.g., 1280x720). Default: None (keep original resolution)")
parser.add_argument("--output", "-o", help="Custom output path. Defaults to media/prepped_<original_name>.mp4")
parser.add_argument("--strip-audio", "-a", action="store_true", help="Completely strip/disable audio stream so the model can generate new audio")
args = parser.parse_args()
try:
info = prep_video(
input_path=args.file,
output_path=args.output,
start_time_str=args.start,
duration=args.duration,
fps=args.fps,
resolution=args.resolution,
strip_audio=args.strip_audio
)
# Display final output report
print(f"\nPrepped Video Specifications: {info['file_name']}")
print("=" * 50)
print(f"File Size : {info['file_size']}")
print(f"Duration : {info['duration']}")
print(f"Bitrate : {info['bitrate']}")
print(f"Resolution : {info['video']['resolution']}")
print(f"Frame Rate : {info['video']['fps']}")
print(f"Video Codec : {info['video']['codec']}")
if info["has_audio"]:
print(f"Audio Spec : {info['audio']['codec']} | {info['audio']['channels']} ch | {info['audio']['sample_rate']}")
print()
except Exception as e:
print(f"Error prepping video: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()