📦 deps(thirdparty): update snapshots
This commit is contained in:
+439
@@ -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()
|
||||
+197
@@ -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()
|
||||
Reference in New Issue
Block a user