You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
392 lines
14 KiB
392 lines
14 KiB
3 months ago
|
from flask import Blueprint, jsonify, current_app, request, Response
|
||
|
import subprocess
|
||
|
import os
|
||
|
import re
|
||
|
import json
|
||
|
import sys
|
||
|
import traceback
|
||
|
from azure.storage.blob import BlobServiceClient
|
||
|
import shutil
|
||
|
from typing import Union
|
||
|
import uuid
|
||
|
import time
|
||
|
import requests
|
||
|
|
||
|
video_rendering_bp = Blueprint("video_rendering", __name__)
|
||
|
|
||
|
|
||
|
USE_LOCAL_STORAGE = os.getenv("USE_LOCAL_STORAGE", "true") == "true"
|
||
|
BASE_URL = os.getenv("BASE_URL", "http://127.0.0.1:8080")
|
||
|
|
||
|
|
||
|
def upload_to_azure_storage(file_path: str, video_storage_file_name: str) -> str:
|
||
|
"""
|
||
|
Uploads the video to Azure Blob Storage and returns the URL.
|
||
|
"""
|
||
|
cloud_file_name = f"{video_storage_file_name}.mp4"
|
||
|
|
||
|
connection_string = os.getenv("AZURE_STORAGE_CONNECTION_STRING")
|
||
|
container_name = os.getenv("AZURE_STORAGE_CONTAINER_NAME")
|
||
|
blob_service_client = BlobServiceClient.from_connection_string(connection_string)
|
||
|
blob_client = blob_service_client.get_blob_client(
|
||
|
container=container_name, blob=cloud_file_name
|
||
|
)
|
||
|
|
||
|
# Upload the video file
|
||
|
with open(file_path, "rb") as data:
|
||
|
blob_client.upload_blob(data, overwrite=True)
|
||
|
|
||
|
# Construct the URL of the uploaded blob
|
||
|
blob_url = f"https://{blob_service_client.account_name}.blob.core.windows.net/{container_name}/{cloud_file_name}"
|
||
|
return blob_url
|
||
|
|
||
|
|
||
|
def move_to_public_folder(
|
||
|
file_path: str, video_storage_file_name: str, base_url: Union[str, None] = None
|
||
|
) -> str:
|
||
|
"""
|
||
|
Moves the video to the public folder and returns the URL.
|
||
|
"""
|
||
|
public_folder = os.path.join(os.path.dirname(__file__), "public")
|
||
|
os.makedirs(public_folder, exist_ok=True)
|
||
|
|
||
|
new_file_name = f"{video_storage_file_name}.mp4"
|
||
|
new_file_path = os.path.join(public_folder, new_file_name)
|
||
|
|
||
|
shutil.move(file_path, new_file_path)
|
||
|
|
||
|
# Use the provided base_url if available, otherwise fall back to BASE_URL
|
||
|
url_base = base_url if base_url else BASE_URL
|
||
|
video_url = f"{url_base.rstrip('/')}/public/{new_file_name}"
|
||
|
return video_url
|
||
|
|
||
|
|
||
|
def get_frame_config(aspect_ratio):
|
||
|
if aspect_ratio == "16:9":
|
||
|
return (3840, 2160), 14.22
|
||
|
elif aspect_ratio == "9:16":
|
||
|
return (1080, 1920), 8.0
|
||
|
elif aspect_ratio == "1:1":
|
||
|
return (1080, 1080), 8.0
|
||
|
else:
|
||
|
return (3840, 2160), 14.22
|
||
|
|
||
|
|
||
|
@video_rendering_bp.route("/v1/video/rendering", methods=["POST"])
|
||
|
def render_video():
|
||
|
# Get the API key from the request headers
|
||
|
# api_key = request.headers.get('X-API-Key')
|
||
|
|
||
|
# if not api_key:
|
||
|
# return jsonify({"error": "API key is missing"}), 401
|
||
|
|
||
|
# Validate the API key and get the user ID
|
||
|
# user_id = get_user_by_api_key(api_key)
|
||
|
|
||
|
# if not user_id:
|
||
|
# return jsonify({"error": "Invalid API key"}), 401
|
||
|
|
||
|
# Now that we have a valid user_id, create a run
|
||
|
# run_id = create_run_on_user(user_id, "video")
|
||
|
|
||
|
# Extract the rest of the request data
|
||
|
code = request.json.get("code")
|
||
|
file_name = request.json.get("file_name")
|
||
|
file_class = request.json.get("file_class")
|
||
|
|
||
|
user_id = request.json.get("user_id") or str(uuid.uuid4())
|
||
|
project_name = request.json.get("project_name")
|
||
|
iteration = request.json.get("iteration")
|
||
|
|
||
|
# Aspect Ratio can be: "16:9" (default), "1:1", "9:16"
|
||
|
aspect_ratio = request.json.get("aspect_ratio")
|
||
|
|
||
|
# Stream the percentage of animation it shown in the error
|
||
|
stream = request.json.get("stream", False)
|
||
|
|
||
|
video_storage_file_name = f"video-{user_id}-{project_name}-{iteration}"
|
||
|
|
||
|
if not code:
|
||
|
return jsonify(error="No code provided"), 400
|
||
|
|
||
|
# Determine frame size and width based on aspect ratio
|
||
|
frame_size, frame_width = get_frame_config(aspect_ratio)
|
||
|
|
||
|
# Modify the Manim script to include configuration settings
|
||
|
modified_code = f"""
|
||
|
from manim import *
|
||
|
from math import *
|
||
|
config.frame_size = {frame_size}
|
||
|
config.frame_width = {frame_width}
|
||
|
|
||
|
{code}
|
||
|
"""
|
||
|
|
||
|
# Create a unique file name
|
||
|
file_name = f"scene_{os.urandom(2).hex()}.py"
|
||
|
|
||
|
# Adjust the path to point to /api/public/
|
||
|
api_dir = os.path.dirname(os.path.dirname(__file__)) # Go up one level from routes
|
||
|
public_dir = os.path.join(api_dir, "public")
|
||
|
os.makedirs(public_dir, exist_ok=True) # Ensure the public directory exists
|
||
|
file_path = os.path.join(public_dir, file_name)
|
||
|
|
||
|
# Write the code to the file
|
||
|
with open(file_path, "w") as f:
|
||
|
f.write(modified_code)
|
||
|
|
||
|
def render_video():
|
||
|
try:
|
||
|
command_list = [
|
||
|
"manim",
|
||
|
file_path, # Use the full path to the file
|
||
|
file_class,
|
||
|
"--format=mp4",
|
||
|
"--media_dir",
|
||
|
".",
|
||
|
"--custom_folders",
|
||
|
]
|
||
|
|
||
|
process = subprocess.Popen(
|
||
|
command_list,
|
||
|
stdout=subprocess.PIPE,
|
||
|
stderr=subprocess.PIPE,
|
||
|
cwd=os.path.dirname(os.path.realpath(__file__)),
|
||
|
text=True,
|
||
|
bufsize=1, # Ensure the output is in text mode and line-buffered
|
||
|
)
|
||
|
current_animation = -1
|
||
|
current_percentage = 0
|
||
|
error_output = []
|
||
|
in_error = False
|
||
|
|
||
|
while True:
|
||
|
output = process.stdout.readline()
|
||
|
error = process.stderr.readline()
|
||
|
|
||
|
if output == "" and error == "" and process.poll() is not None:
|
||
|
break
|
||
|
|
||
|
if output:
|
||
|
print("STDOUT:", output.strip())
|
||
|
if error:
|
||
|
print("STDERR:", error.strip())
|
||
|
error_output.append(error.strip())
|
||
|
|
||
|
# Check for critical errors
|
||
|
if "is not in the script" in error:
|
||
|
in_error = True
|
||
|
continue
|
||
|
|
||
|
# Check for start of error
|
||
|
if "Traceback (most recent call last)" in error:
|
||
|
in_error = True
|
||
|
continue
|
||
|
|
||
|
# If we're in an error state, keep accumulating the error message
|
||
|
if in_error:
|
||
|
if error.strip() == "":
|
||
|
# Empty line might indicate end of traceback
|
||
|
in_error = False
|
||
|
full_error = "\n".join(error_output)
|
||
|
yield f'{{"error": {json.dumps(full_error)}}}\n'
|
||
|
return
|
||
|
continue
|
||
|
|
||
|
animation_match = re.search(r"Animation (\d+):", error)
|
||
|
if animation_match:
|
||
|
new_animation = int(animation_match.group(1))
|
||
|
if new_animation != current_animation:
|
||
|
current_animation = new_animation
|
||
|
current_percentage = 0
|
||
|
yield f'{{"animationIndex": {current_animation}, "percentage": 0}}\n'
|
||
|
|
||
|
percentage_match = re.search(r"(\d+)%", error)
|
||
|
if percentage_match:
|
||
|
new_percentage = int(percentage_match.group(1))
|
||
|
if new_percentage != current_percentage:
|
||
|
current_percentage = new_percentage
|
||
|
yield f'{{"animationIndex": {current_animation}, "percentage": {current_percentage}}}\n'
|
||
|
|
||
|
if process.returncode == 0:
|
||
|
# Update this part
|
||
|
video_file_path = os.path.join(
|
||
|
os.path.dirname(os.path.realpath(__file__)),
|
||
|
f"{file_class or 'GenScene'}.mp4"
|
||
|
)
|
||
|
# Looking for video file at: {video_file_path}
|
||
|
|
||
|
if not os.path.exists(video_file_path):
|
||
|
# Video file not found. Searching in parent directory...
|
||
|
video_file_path = os.path.join(
|
||
|
os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
|
||
|
f"{file_class or 'GenScene'}.mp4"
|
||
|
)
|
||
|
# New video file path is: {video_file_path}
|
||
|
|
||
|
if os.path.exists(video_file_path):
|
||
|
print(f"Video file found at: {video_file_path}")
|
||
|
else:
|
||
|
print(f"Video file not found. Files in current directory: {os.listdir(os.path.dirname(video_file_path))}")
|
||
|
raise FileNotFoundError(f"Video file not found at {video_file_path}")
|
||
|
|
||
|
print(f"Files in video file directory: {os.listdir(os.path.dirname(video_file_path))}")
|
||
|
|
||
|
if USE_LOCAL_STORAGE:
|
||
|
# Pass request.host_url if available
|
||
|
base_url = (
|
||
|
request.host_url
|
||
|
if request and hasattr(request, "host_url")
|
||
|
else None
|
||
|
)
|
||
|
video_url = move_to_public_folder(
|
||
|
video_file_path, video_storage_file_name, base_url
|
||
|
)
|
||
|
else:
|
||
|
video_url = upload_to_azure_storage(
|
||
|
video_file_path, video_storage_file_name
|
||
|
)
|
||
|
print(f"Video URL: {video_url}")
|
||
|
if stream:
|
||
|
yield f'{{ "video_url": "{video_url}" }}\n'
|
||
|
sys.stdout.flush()
|
||
|
else:
|
||
|
yield {
|
||
|
"message": "Video generation completed",
|
||
|
"video_url": video_url,
|
||
|
}
|
||
|
else:
|
||
|
full_error = "\n".join(error_output)
|
||
|
yield f'{{"error": {json.dumps(full_error)}}}\n'
|
||
|
|
||
|
except Exception as e:
|
||
|
print(f"Unexpected error: {str(e)}")
|
||
|
traceback.print_exc()
|
||
|
print(f"Files in current directory after error: {os.listdir('.')}")
|
||
|
yield f'{{"error": "Unexpected error occurred: {str(e)}"}}\n'
|
||
|
finally:
|
||
|
# Remove the temporary Python file
|
||
|
try:
|
||
|
if os.path.exists(file_path):
|
||
|
os.remove(file_path)
|
||
|
print(f"Removed temporary file: {file_path}")
|
||
|
# Remove the video file
|
||
|
if os.path.exists(video_file_path):
|
||
|
os.remove(video_file_path)
|
||
|
print(f"Removed temporary video file: {video_file_path}")
|
||
|
except Exception as e:
|
||
|
print(f"Error removing temporary file {file_path}: {e}")
|
||
|
|
||
|
if stream:
|
||
|
# TODO: If the `render_video()` fails, or it's sending {"error"}, be sure to add `500`
|
||
|
return Response(
|
||
|
render_video(), content_type="text/event-stream", status=207
|
||
|
)
|
||
|
else:
|
||
|
video_url = None
|
||
|
try:
|
||
|
for result in render_video(): # Iterate through the generator
|
||
|
print(f"Generated result: {result}") # Debug print
|
||
|
if isinstance(result, dict):
|
||
|
if "video_url" in result:
|
||
|
video_url = result["video_url"]
|
||
|
elif "error" in result:
|
||
|
raise Exception(result["error"])
|
||
|
|
||
|
if video_url:
|
||
|
return (
|
||
|
jsonify(
|
||
|
{
|
||
|
"message": "Video generation completed",
|
||
|
"video_url": video_url,
|
||
|
}
|
||
|
),
|
||
|
200,
|
||
|
)
|
||
|
else:
|
||
|
return (
|
||
|
jsonify(
|
||
|
{
|
||
|
"message": "Video generation completed, but no URL was found"
|
||
|
}
|
||
|
),
|
||
|
200,
|
||
|
)
|
||
|
except StopIteration:
|
||
|
if video_url:
|
||
|
return (
|
||
|
jsonify(
|
||
|
{
|
||
|
"message": "Video generation completed",
|
||
|
"video_url": video_url,
|
||
|
}
|
||
|
),
|
||
|
200,
|
||
|
)
|
||
|
else:
|
||
|
return (
|
||
|
jsonify(
|
||
|
{
|
||
|
"message": "Video generation completed, but no URL was found"
|
||
|
}
|
||
|
),
|
||
|
200,
|
||
|
)
|
||
|
except Exception as e:
|
||
|
print(f"Error in non-streaming mode: {e}")
|
||
|
return jsonify({"error": str(e)}), 500
|
||
|
|
||
|
|
||
|
@video_rendering_bp.route("/v1/video/exporting", methods=["POST"])
|
||
|
def export_video():
|
||
|
scenes = request.json.get("scenes")
|
||
|
title_slug = request.json.get("titleSlug")
|
||
|
local_filenames = []
|
||
|
|
||
|
# Download each scene
|
||
|
for scene in scenes:
|
||
|
video_url = scene["videoUrl"]
|
||
|
object_name = video_url.split("/")[-1]
|
||
|
local_filename = download_video(video_url)
|
||
|
local_filenames.append(local_filename)
|
||
|
|
||
|
# Create a list of input file arguments for ffmpeg
|
||
|
input_files = " ".join([f"-i {filename}" for filename in local_filenames])
|
||
|
|
||
|
# Generate a unique filename with UNIX timestamp
|
||
|
timestamp = int(time.time())
|
||
|
merged_filename = os.path.join(
|
||
|
os.getcwd(), f"exported-scene-{title_slug}-{timestamp}.mp4"
|
||
|
)
|
||
|
|
||
|
# Command to merge videos using ffmpeg
|
||
|
command = f"ffmpeg {input_files} -filter_complex 'concat=n={len(local_filenames)}:v=1:a=0[out]' -map '[out]' {merged_filename}"
|
||
|
|
||
|
try:
|
||
|
# Execute the ffmpeg command
|
||
|
subprocess.run(command, shell=True, check=True)
|
||
|
print("Videos merged successfully.")
|
||
|
print(f"merged_filename: {merged_filename}")
|
||
|
public_url = upload_to_azure_storage(
|
||
|
merged_filename, f"exported-scene-{title_slug}-{timestamp}"
|
||
|
)
|
||
|
print(f"Video URL: {public_url}")
|
||
|
return jsonify(
|
||
|
{"status": "Videos merged successfully", "video_url": public_url}
|
||
|
)
|
||
|
except subprocess.CalledProcessError as e:
|
||
|
print(f"ffmpeg error: {e}")
|
||
|
return jsonify({"error": "Failed to merge videos"}), 500
|
||
|
except Exception as e:
|
||
|
print(f"Error: {e}")
|
||
|
return jsonify({"error": str(e)}), 500
|
||
|
|
||
|
|
||
|
def download_video(video_url):
|
||
|
local_filename = video_url.split("/")[-1]
|
||
|
response = requests.get(video_url)
|
||
|
response.raise_for_status()
|
||
|
with open(local_filename, 'wb') as f:
|
||
|
f.write(response.content)
|
||
|
return local_filename
|