|
|
|
|
|
|
|
|
import requests |
|
|
import uuid |
|
|
import time |
|
|
import brotli |
|
|
import json |
|
|
import asyncio |
|
|
from typing import Literal |
|
|
|
|
|
from fastapi import FastAPI, HTTPException |
|
|
from pydantic import BaseModel, Field |
|
|
|
|
|
|
|
|
app = FastAPI( |
|
|
title="Sora Video Generation API", |
|
|
description="An unofficial API wrapper to generate videos using the Bylo.ai Sora model.", |
|
|
version="1.0.0", |
|
|
) |
|
|
|
|
|
|
|
|
class SoraRequest(BaseModel): |
|
|
prompt: str = Field(..., description="The text prompt to generate the video from.") |
|
|
ratio: Literal["portrait", "landscape"] = Field( |
|
|
"portrait", |
|
|
description="The aspect ratio of the generated video." |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
def decode_response(resp: requests.Response): |
|
|
""" |
|
|
Safely decode response: Brotli β UTF-8 β JSON, with automatic fallback if Brotli fails. |
|
|
""" |
|
|
raw_data = resp.content |
|
|
encoding = resp.headers.get("Content-Encoding", "").lower() |
|
|
|
|
|
if encoding == "br": |
|
|
try: |
|
|
raw_data = brotli.decompress(raw_data) |
|
|
except brotli.error: |
|
|
pass |
|
|
|
|
|
try: |
|
|
return json.loads(raw_data.decode("utf-8")) |
|
|
except Exception as je: |
|
|
snippet = raw_data[:500] |
|
|
raise RuntimeError(f"Failed to decode JSON: {je}\nRaw response snippet:\n{snippet}") |
|
|
|
|
|
def sora(prompt: str, ratio: str = "portrait"): |
|
|
""" |
|
|
The core function to interact with the Bylo.ai Sora API. |
|
|
This is a synchronous function intended to be run in a thread. |
|
|
""" |
|
|
if not prompt: |
|
|
raise ValueError("Prompt is required.") |
|
|
if ratio not in ["portrait", "landscape"]: |
|
|
raise ValueError("Available ratios: portrait, landscape.") |
|
|
|
|
|
base_url = "https://api.bylo.ai/aimodels/api/v1/ai" |
|
|
|
|
|
headers = { |
|
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", |
|
|
"accept-language": "id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7", |
|
|
"accept-encoding": "gzip, deflate, br", |
|
|
"cache-control": "max-age=0", |
|
|
"connection": "keep-alive", |
|
|
"content-type": "application/json; charset=UTF-8", |
|
|
"dnt": "1", |
|
|
"origin": "https://bylo.ai", |
|
|
"pragma": "no-cache", |
|
|
"referer": "https://bylo.ai/features/sora-2", |
|
|
"sec-ch-prefers-color-scheme": "dark", |
|
|
"sec-ch-ua": '"Chromium";v="137", "Not/A)Brand";v="24"', |
|
|
"sec-ch-ua-mobile": "?1", |
|
|
"user-agent": "Mozilla/5.0 (Linux; Android 15; SM-F958 Build/AP3A.240905.015) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.86 Mobile Safari/537.36", |
|
|
"x-requested-with": "XMLHttpRequest", |
|
|
"uniqueId": str(uuid.uuid4()).replace("-", "") |
|
|
} |
|
|
|
|
|
payload = { |
|
|
"prompt": prompt, |
|
|
"channel": "SORA2", |
|
|
"pageId": 536, |
|
|
"source": "bylo.ai", |
|
|
"watermarkFlag": True, |
|
|
"privateFlag": True, |
|
|
"isTemp": True, |
|
|
"vipFlag": True, |
|
|
"model": "sora_video2", |
|
|
"videoType": "text-to-video", |
|
|
"aspectRatio": ratio |
|
|
} |
|
|
|
|
|
try: |
|
|
|
|
|
create_resp = requests.post(f"{base_url}/video/create", headers=headers, json=payload) |
|
|
create_resp.raise_for_status() |
|
|
task_data = decode_response(create_resp) |
|
|
|
|
|
task_id = task_data.get("data") |
|
|
if not task_id: |
|
|
raise RuntimeError(f"API did not return task_id. Full response:\n{json.dumps(task_data, indent=2)}") |
|
|
|
|
|
|
|
|
|
|
|
polling_start_time = time.time() |
|
|
timeout_seconds = 300 |
|
|
|
|
|
while True: |
|
|
if time.time() - polling_start_time > timeout_seconds: |
|
|
raise RuntimeError("Polling timed out after 5 minutes.") |
|
|
|
|
|
poll_resp = requests.get(f"{base_url}/{task_id}?channel=SORA2", headers=headers) |
|
|
poll_resp.raise_for_status() |
|
|
poll_json = decode_response(poll_resp) |
|
|
|
|
|
state = poll_json.get("data", {}).get("state", 0) |
|
|
if state > 0: |
|
|
complete_data_str = poll_json.get("data", {}).get("completeData") |
|
|
if not complete_data_str: |
|
|
raise RuntimeError(f"Generation completed but no 'completeData' found. Full response: \n{poll_json}") |
|
|
return json.loads(complete_data_str) |
|
|
|
|
|
time.sleep(5) |
|
|
|
|
|
except requests.exceptions.RequestException as re: |
|
|
raise RuntimeError(f"Network error during Sora generation: {re}") |
|
|
except Exception as e: |
|
|
|
|
|
raise RuntimeError(f"Sora generation failed: {e}") |
|
|
|
|
|
|
|
|
|
|
|
@app.post("/generate", summary="Create a new Sora video") |
|
|
async def generate_video(request: SoraRequest): |
|
|
""" |
|
|
Accepts a prompt and ratio, then generates a video by polling the Bylo.ai API. |
|
|
""" |
|
|
try: |
|
|
|
|
|
result = await asyncio.to_thread(sora, request.prompt, request.ratio) |
|
|
return result |
|
|
except ValueError as ve: |
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(ve)) |
|
|
except RuntimeError as re: |
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(re)) |
|
|
|
|
|
@app.get("/", include_in_schema=False) |
|
|
def root(): |
|
|
return {"message": "Sora API is running. See /docs for usage."} |