初始化提交
This commit is contained in:
31
app/controllers/base.py
Normal file
31
app/controllers/base.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.config import config
|
||||
from app.models.exception import HttpException
|
||||
|
||||
|
||||
def get_task_id(request: Request):
|
||||
task_id = request.headers.get("x-task-id")
|
||||
if not task_id:
|
||||
task_id = uuid4()
|
||||
return str(task_id)
|
||||
|
||||
|
||||
def get_api_key(request: Request):
|
||||
api_key = request.headers.get("x-api-key")
|
||||
return api_key
|
||||
|
||||
|
||||
def verify_token(request: Request):
|
||||
token = get_api_key(request)
|
||||
if token != config.app.get("api_key", ""):
|
||||
request_id = get_task_id(request)
|
||||
request_url = request.url
|
||||
user_agent = request.headers.get("user-agent")
|
||||
raise HttpException(
|
||||
task_id=request_id,
|
||||
status_code=401,
|
||||
message=f"invalid token: {request_url}, {user_agent}",
|
||||
)
|
||||
87
app/controllers/manager/base_manager.py
Normal file
87
app/controllers/manager/base_manager.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import threading
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class TaskQueueFullError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self, max_concurrent_tasks: int, max_queued_tasks: int = 100):
|
||||
self.max_concurrent_tasks = max_concurrent_tasks
|
||||
self.max_queued_tasks = max_queued_tasks
|
||||
self.current_tasks = 0
|
||||
self.lock = threading.Lock()
|
||||
self.queue = self.create_queue()
|
||||
|
||||
def create_queue(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def add_task(self, func: Callable, *args: Any, **kwargs: Any):
|
||||
with self.lock:
|
||||
if self.current_tasks < self.max_concurrent_tasks:
|
||||
logger.info(
|
||||
f"add task: {func.__name__}, current_tasks: {self.current_tasks}"
|
||||
)
|
||||
self.execute_task(func, *args, **kwargs)
|
||||
else:
|
||||
queue_size = self.queue_size()
|
||||
# 并发数已满时才进入排队。队列必须有上限,否则匿名接口可以持续
|
||||
# 堆积任务对象和请求参数,最终造成内存耗尽或第三方 API 成本失控。
|
||||
if queue_size >= self.max_queued_tasks:
|
||||
logger.warning(
|
||||
f"reject task: {func.__name__}, queue_size: {queue_size}, "
|
||||
f"max_queued_tasks: {self.max_queued_tasks}"
|
||||
)
|
||||
raise TaskQueueFullError("task queue is full, please try again later")
|
||||
|
||||
logger.info(
|
||||
f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}, "
|
||||
f"queue_size: {queue_size}"
|
||||
)
|
||||
self.enqueue({"func": func, "args": args, "kwargs": kwargs})
|
||||
|
||||
def execute_task(self, func: Callable, *args: Any, **kwargs: Any):
|
||||
thread = threading.Thread(
|
||||
target=self.run_task, args=(func, *args), kwargs=kwargs
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def run_task(self, func: Callable, *args: Any, **kwargs: Any):
|
||||
try:
|
||||
with self.lock:
|
||||
self.current_tasks += 1
|
||||
func(*args, **kwargs) # call the function here, passing *args and **kwargs.
|
||||
finally:
|
||||
self.task_done()
|
||||
|
||||
def check_queue(self):
|
||||
with self.lock:
|
||||
if (
|
||||
self.current_tasks < self.max_concurrent_tasks
|
||||
and not self.is_queue_empty()
|
||||
):
|
||||
task_info = self.dequeue()
|
||||
func = task_info["func"]
|
||||
args = task_info.get("args", ())
|
||||
kwargs = task_info.get("kwargs", {})
|
||||
self.execute_task(func, *args, **kwargs)
|
||||
|
||||
def task_done(self):
|
||||
with self.lock:
|
||||
self.current_tasks -= 1
|
||||
self.check_queue()
|
||||
|
||||
def enqueue(self, task: Dict):
|
||||
raise NotImplementedError()
|
||||
|
||||
def dequeue(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def is_queue_empty(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def queue_size(self):
|
||||
raise NotImplementedError()
|
||||
21
app/controllers/manager/memory_manager.py
Normal file
21
app/controllers/manager/memory_manager.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from queue import Queue
|
||||
from typing import Dict
|
||||
|
||||
from app.controllers.manager.base_manager import TaskManager
|
||||
|
||||
|
||||
class InMemoryTaskManager(TaskManager):
|
||||
def create_queue(self):
|
||||
return Queue(maxsize=self.max_queued_tasks)
|
||||
|
||||
def enqueue(self, task: Dict):
|
||||
self.queue.put(task)
|
||||
|
||||
def dequeue(self):
|
||||
return self.queue.get()
|
||||
|
||||
def is_queue_empty(self):
|
||||
return self.queue.empty()
|
||||
|
||||
def queue_size(self):
|
||||
return self.queue.qsize()
|
||||
64
app/controllers/manager/redis_manager.py
Normal file
64
app/controllers/manager/redis_manager.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
from typing import Dict
|
||||
|
||||
import redis
|
||||
|
||||
from app.controllers.manager.base_manager import TaskManager
|
||||
from app.models.schema import VideoParams
|
||||
from app.services import task as tm
|
||||
|
||||
FUNC_MAP = {
|
||||
"start": tm.start,
|
||||
# 'start_test': tm.start_test
|
||||
}
|
||||
|
||||
|
||||
class RedisTaskManager(TaskManager):
|
||||
def __init__(
|
||||
self,
|
||||
max_concurrent_tasks: int,
|
||||
redis_url: str,
|
||||
max_queued_tasks: int = 100,
|
||||
):
|
||||
self.redis_client = redis.Redis.from_url(redis_url)
|
||||
super().__init__(max_concurrent_tasks, max_queued_tasks=max_queued_tasks)
|
||||
|
||||
def create_queue(self):
|
||||
return "task_queue"
|
||||
|
||||
def enqueue(self, task: Dict):
|
||||
task_with_serializable_params = task.copy()
|
||||
|
||||
if "params" in task["kwargs"] and isinstance(
|
||||
task["kwargs"]["params"], VideoParams
|
||||
):
|
||||
task_with_serializable_params["kwargs"]["params"] = task["kwargs"][
|
||||
"params"
|
||||
].dict()
|
||||
|
||||
# 将函数对象转换为其名称
|
||||
task_with_serializable_params["func"] = task["func"].__name__
|
||||
self.redis_client.rpush(self.queue, json.dumps(task_with_serializable_params))
|
||||
|
||||
def dequeue(self):
|
||||
task_json = self.redis_client.lpop(self.queue)
|
||||
if task_json:
|
||||
task_info = json.loads(task_json)
|
||||
# 将函数名称转换回函数对象
|
||||
task_info["func"] = FUNC_MAP[task_info["func"]]
|
||||
|
||||
if "params" in task_info["kwargs"] and isinstance(
|
||||
task_info["kwargs"]["params"], dict
|
||||
):
|
||||
task_info["kwargs"]["params"] = VideoParams(
|
||||
**task_info["kwargs"]["params"]
|
||||
)
|
||||
|
||||
return task_info
|
||||
return None
|
||||
|
||||
def is_queue_empty(self):
|
||||
return self.redis_client.llen(self.queue) == 0
|
||||
|
||||
def queue_size(self):
|
||||
return self.redis_client.llen(self.queue)
|
||||
13
app/controllers/ping.py
Normal file
13
app/controllers/ping.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ping",
|
||||
tags=["Health Check"],
|
||||
description="检查服务可用性",
|
||||
response_description="pong",
|
||||
)
|
||||
def ping(request: Request) -> str:
|
||||
return "pong"
|
||||
11
app/controllers/v1/base.py
Normal file
11
app/controllers/v1/base.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
def new_router(dependencies=None):
|
||||
router = APIRouter()
|
||||
router.tags = ["V1"]
|
||||
router.prefix = "/api/v1"
|
||||
# 将认证依赖项应用于所有路由
|
||||
if dependencies:
|
||||
router.dependencies = dependencies
|
||||
return router
|
||||
47
app/controllers/v1/llm.py
Normal file
47
app/controllers/v1/llm.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from fastapi import Request
|
||||
|
||||
from app.controllers.v1.base import new_router
|
||||
from app.models.schema import (
|
||||
VideoScriptRequest,
|
||||
VideoScriptResponse,
|
||||
VideoTermsRequest,
|
||||
VideoTermsResponse,
|
||||
)
|
||||
from app.services import llm
|
||||
from app.utils import utils
|
||||
|
||||
# authentication dependency
|
||||
# router = new_router(dependencies=[Depends(base.verify_token)])
|
||||
router = new_router()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scripts",
|
||||
response_model=VideoScriptResponse,
|
||||
summary="Create a script for the video",
|
||||
)
|
||||
def generate_video_script(request: Request, body: VideoScriptRequest):
|
||||
video_script = llm.generate_script(
|
||||
video_subject=body.video_subject,
|
||||
language=body.video_language,
|
||||
paragraph_number=body.paragraph_number,
|
||||
video_script_prompt=body.video_script_prompt,
|
||||
custom_system_prompt=body.custom_system_prompt,
|
||||
)
|
||||
response = {"video_script": video_script}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/terms",
|
||||
response_model=VideoTermsResponse,
|
||||
summary="Generate video terms based on the video script",
|
||||
)
|
||||
def generate_video_terms(request: Request, body: VideoTermsRequest):
|
||||
video_terms = llm.generate_terms(
|
||||
video_subject=body.video_subject,
|
||||
video_script=body.video_script,
|
||||
amount=body.amount,
|
||||
)
|
||||
response = {"video_terms": video_terms}
|
||||
return utils.get_response(200, response)
|
||||
403
app/controllers/v1/video.py
Normal file
403
app/controllers/v1/video.py
Normal file
@@ -0,0 +1,403 @@
|
||||
import glob
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
from typing import Union
|
||||
|
||||
from fastapi import BackgroundTasks, Depends, Path, Request, UploadFile
|
||||
from fastapi.params import File
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from loguru import logger
|
||||
|
||||
from app.config import config
|
||||
from app.controllers import base
|
||||
from app.controllers.manager.base_manager import TaskQueueFullError
|
||||
from app.controllers.manager.memory_manager import InMemoryTaskManager
|
||||
from app.controllers.manager.redis_manager import RedisTaskManager
|
||||
from app.controllers.v1.base import new_router
|
||||
from app.models.exception import HttpException
|
||||
from app.models.schema import (
|
||||
AudioRequest,
|
||||
BgmRetrieveResponse,
|
||||
BgmUploadResponse,
|
||||
SubtitleRequest,
|
||||
TaskDeletionResponse,
|
||||
TaskQueryRequest,
|
||||
TaskQueryResponse,
|
||||
TaskResponse,
|
||||
TaskVideoRequest,
|
||||
VideoMaterialUploadResponse,
|
||||
VideoMaterialRetrieveResponse
|
||||
)
|
||||
from app.services import state as sm
|
||||
from app.services import task as tm
|
||||
from app.utils import file_security, utils
|
||||
|
||||
# 认证依赖项
|
||||
# router = new_router(dependencies=[Depends(base.verify_token)])
|
||||
router = new_router()
|
||||
|
||||
_enable_redis = config.app.get("enable_redis", False)
|
||||
_redis_host = config.app.get("redis_host", "localhost")
|
||||
_redis_port = config.app.get("redis_port", 6379)
|
||||
_redis_db = config.app.get("redis_db", 0)
|
||||
_redis_password = config.app.get("redis_password", None)
|
||||
_max_concurrent_tasks = config.app.get("max_concurrent_tasks", 5)
|
||||
_max_queued_tasks = config.app.get("max_queued_tasks", 100)
|
||||
|
||||
redis_url = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/{_redis_db}"
|
||||
# 根据配置选择合适的任务管理器
|
||||
if _enable_redis:
|
||||
task_manager = RedisTaskManager(
|
||||
max_concurrent_tasks=_max_concurrent_tasks,
|
||||
redis_url=redis_url,
|
||||
max_queued_tasks=_max_queued_tasks,
|
||||
)
|
||||
else:
|
||||
task_manager = InMemoryTaskManager(
|
||||
max_concurrent_tasks=_max_concurrent_tasks,
|
||||
max_queued_tasks=_max_queued_tasks,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_upload_filename(filename: str, request_id: str) -> str:
|
||||
# 浏览器或客户端有时会附带目录信息,甚至可能夹带 ../ 这类穿越片段。
|
||||
# 这里只保留纯文件名,避免上传接口把文件写到目标目录之外。
|
||||
normalized_name = (filename or "").replace("\\", "/").split("/")[-1].strip()
|
||||
if not normalized_name or normalized_name in {".", ".."}:
|
||||
raise HttpException(
|
||||
task_id=request_id,
|
||||
status_code=400,
|
||||
message=f"{request_id}: invalid filename",
|
||||
)
|
||||
return normalized_name
|
||||
|
||||
|
||||
def _resolve_path_within_directory(base_dir: str, unsafe_path: str, request_id: str) -> str:
|
||||
try:
|
||||
return file_security.resolve_path_within_directory(base_dir, unsafe_path)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
f"reject unsafe file path, request_id: {request_id}, path: {unsafe_path}, "
|
||||
f"error: {str(exc)}"
|
||||
)
|
||||
raise HttpException(
|
||||
task_id=request_id,
|
||||
status_code=404 if str(exc) == "file does not exist" else 403,
|
||||
message=f"{request_id}: invalid file path",
|
||||
)
|
||||
|
||||
def _task_file_to_uri(file: str, endpoint: str, task_dir: str, request_id: str) -> str:
|
||||
if not isinstance(file, str):
|
||||
return file
|
||||
|
||||
if file.startswith(("http://", "https://")):
|
||||
return file
|
||||
|
||||
try:
|
||||
resolved_path = file_security.resolve_path_within_directory(task_dir, file)
|
||||
except ValueError as exc:
|
||||
# 任务状态理论上只应保存任务目录内的产物路径。这里不再继续拼接 URL,
|
||||
# 避免把异常路径包装成可访问链接;同时保留原值,便于排查历史脏数据。
|
||||
logger.warning(
|
||||
f"skip unsafe task output path, request_id: {request_id}, path: {file}, "
|
||||
f"error: {str(exc)}"
|
||||
)
|
||||
return file
|
||||
|
||||
relative_path = os.path.relpath(resolved_path, task_dir).replace("\\", "/")
|
||||
uri_path = f"tasks/{relative_path}"
|
||||
if endpoint:
|
||||
return f"{endpoint.rstrip('/')}/{uri_path}"
|
||||
return f"/{uri_path}"
|
||||
|
||||
|
||||
@router.post("/videos", response_model=TaskResponse, summary="Generate a short video")
|
||||
def create_video(
|
||||
background_tasks: BackgroundTasks, request: Request, body: TaskVideoRequest
|
||||
):
|
||||
return create_task(request, body, stop_at="video")
|
||||
|
||||
|
||||
@router.post("/subtitle", response_model=TaskResponse, summary="Generate subtitle only")
|
||||
def create_subtitle(
|
||||
background_tasks: BackgroundTasks, request: Request, body: SubtitleRequest
|
||||
):
|
||||
return create_task(request, body, stop_at="subtitle")
|
||||
|
||||
|
||||
@router.post("/audio", response_model=TaskResponse, summary="Generate audio only")
|
||||
def create_audio(
|
||||
background_tasks: BackgroundTasks, request: Request, body: AudioRequest
|
||||
):
|
||||
return create_task(request, body, stop_at="audio")
|
||||
|
||||
|
||||
def create_task(
|
||||
request: Request,
|
||||
body: Union[TaskVideoRequest, SubtitleRequest, AudioRequest],
|
||||
stop_at: str,
|
||||
):
|
||||
task_id = utils.get_uuid()
|
||||
request_id = base.get_task_id(request)
|
||||
try:
|
||||
task = {
|
||||
"task_id": task_id,
|
||||
"request_id": request_id,
|
||||
"params": body.model_dump(),
|
||||
}
|
||||
sm.state.update_task(task_id)
|
||||
task_manager.add_task(tm.start, task_id=task_id, params=body, stop_at=stop_at)
|
||||
logger.success(f"Task created: {utils.to_json(task)}")
|
||||
return utils.get_response(200, task)
|
||||
except TaskQueueFullError as e:
|
||||
sm.state.delete_task(task_id)
|
||||
logger.warning(
|
||||
f"reject task because queue is full, request_id: {request_id}, task_id: {task_id}"
|
||||
)
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=429, message=f"{request_id}: {str(e)}"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}"
|
||||
)
|
||||
|
||||
from fastapi import Query
|
||||
|
||||
@router.get("/tasks", response_model=TaskQueryResponse, summary="Get all tasks")
|
||||
def get_all_tasks(request: Request, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1)):
|
||||
request_id = base.get_task_id(request)
|
||||
tasks, total = sm.state.get_all_tasks(page, page_size)
|
||||
|
||||
response = {
|
||||
"tasks": tasks,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tasks/{task_id}", response_model=TaskQueryResponse, summary="Query task status"
|
||||
)
|
||||
def get_task(
|
||||
request: Request,
|
||||
task_id: str = Path(..., description="Task ID"),
|
||||
query: TaskQueryRequest = Depends(),
|
||||
):
|
||||
request_id = base.get_task_id(request)
|
||||
endpoint = config.app.get("endpoint", "").rstrip("/")
|
||||
task = sm.state.get_task(task_id)
|
||||
if task:
|
||||
task_dir = utils.task_dir()
|
||||
response_task = dict(task)
|
||||
|
||||
if "videos" in task:
|
||||
response_task["videos"] = [
|
||||
_task_file_to_uri(v, endpoint, task_dir, request_id)
|
||||
for v in task["videos"]
|
||||
]
|
||||
if "combined_videos" in task:
|
||||
response_task["combined_videos"] = [
|
||||
_task_file_to_uri(v, endpoint, task_dir, request_id)
|
||||
for v in task["combined_videos"]
|
||||
]
|
||||
return utils.get_response(200, response_task)
|
||||
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tasks/{task_id}",
|
||||
response_model=TaskDeletionResponse,
|
||||
summary="Delete a generated short video task",
|
||||
)
|
||||
def delete_video(request: Request, task_id: str = Path(..., description="Task ID")):
|
||||
request_id = base.get_task_id(request)
|
||||
task = sm.state.get_task(task_id)
|
||||
if task:
|
||||
tasks_dir = utils.task_dir()
|
||||
current_task_dir = os.path.join(tasks_dir, task_id)
|
||||
if os.path.exists(current_task_dir):
|
||||
shutil.rmtree(current_task_dir)
|
||||
|
||||
sm.state.delete_task(task_id)
|
||||
logger.success(f"video deleted: {utils.to_json(task)}")
|
||||
return utils.get_response(200)
|
||||
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=404, message=f"{request_id}: task not found"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/musics", response_model=BgmRetrieveResponse, summary="Retrieve local BGM files"
|
||||
)
|
||||
def get_bgm_list(request: Request):
|
||||
suffix = "*.mp3"
|
||||
song_dir = utils.song_dir()
|
||||
files = glob.glob(os.path.join(song_dir, suffix))
|
||||
bgm_list = []
|
||||
for file in files:
|
||||
filename = os.path.basename(file)
|
||||
bgm_list.append(
|
||||
{
|
||||
"name": filename,
|
||||
"size": os.path.getsize(file),
|
||||
# 只返回文件名,避免把服务器绝对路径暴露给调用方。
|
||||
# 服务端后续会把该文件名解析回 songs 白名单目录。
|
||||
"file": filename,
|
||||
}
|
||||
)
|
||||
response = {"files": bgm_list}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/musics",
|
||||
response_model=BgmUploadResponse,
|
||||
summary="Upload the BGM file to the songs directory",
|
||||
)
|
||||
def upload_bgm_file(request: Request, file: UploadFile = File(...)):
|
||||
request_id = base.get_task_id(request)
|
||||
safe_filename = _sanitize_upload_filename(file.filename, request_id)
|
||||
# check file ext
|
||||
if safe_filename.lower().endswith("mp3"):
|
||||
song_dir = utils.song_dir()
|
||||
save_path = os.path.join(song_dir, safe_filename)
|
||||
# save file
|
||||
with open(save_path, "wb+") as buffer:
|
||||
# If the file already exists, it will be overwritten
|
||||
file.file.seek(0)
|
||||
buffer.write(file.file.read())
|
||||
response = {"file": safe_filename}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
raise HttpException(
|
||||
"", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded"
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/video_materials", response_model=VideoMaterialRetrieveResponse, summary="Retrieve local video materials"
|
||||
)
|
||||
def get_video_materials_list(request: Request):
|
||||
allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png")
|
||||
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
||||
files = []
|
||||
for suffix in allowed_suffixes:
|
||||
files.extend(glob.glob(os.path.join(local_videos_dir, f"*.{suffix}")))
|
||||
# 文件系统枚举顺序不稳定,直接返回会导致“顺序拼接”在不同机器或不同
|
||||
# 时刻表现不一致。这里统一按文件名排序,至少保证服务端返回顺序可预测。
|
||||
files.sort(key=lambda file_path: os.path.basename(file_path).lower())
|
||||
video_materials_list = []
|
||||
for file in files:
|
||||
filename = os.path.basename(file)
|
||||
video_materials_list.append(
|
||||
{
|
||||
"name": filename,
|
||||
"size": os.path.getsize(file),
|
||||
# 与 BGM 一样,只返回文件名;创建任务时再在 local_videos
|
||||
# 白名单目录内解析,避免 API 泄露宿主机绝对路径。
|
||||
"file": filename,
|
||||
}
|
||||
)
|
||||
response = {"files": video_materials_list}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/video_materials",
|
||||
response_model=VideoMaterialUploadResponse,
|
||||
summary="Upload the video material file to the local videos directory",
|
||||
)
|
||||
def upload_video_material_file(request: Request, file: UploadFile = File(...)):
|
||||
request_id = base.get_task_id(request)
|
||||
safe_filename = _sanitize_upload_filename(file.filename, request_id)
|
||||
# check file ext
|
||||
allowed_suffixes = ("mp4", "mov", "avi", "flv", "mkv", "jpg", "jpeg", "png")
|
||||
normalized_filename = safe_filename.lower()
|
||||
# 统一按小写扩展名校验,兼容 .MOV 这类大写后缀文件。
|
||||
if normalized_filename.endswith(allowed_suffixes):
|
||||
local_videos_dir = utils.storage_dir("local_videos", create=True)
|
||||
save_path = os.path.join(local_videos_dir, safe_filename)
|
||||
# save file
|
||||
with open(save_path, "wb+") as buffer:
|
||||
# If the file already exists, it will be overwritten
|
||||
file.file.seek(0)
|
||||
buffer.write(file.file.read())
|
||||
response = {"file": safe_filename}
|
||||
return utils.get_response(200, response)
|
||||
|
||||
raise HttpException(
|
||||
"", status_code=400, message=f"{request_id}: Only files with extensions {', '.join(allowed_suffixes)} can be uploaded"
|
||||
)
|
||||
|
||||
@router.get("/stream/{file_path:path}")
|
||||
async def stream_video(request: Request, file_path: str):
|
||||
request_id = base.get_task_id(request)
|
||||
tasks_dir = utils.task_dir()
|
||||
video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id)
|
||||
range_header = request.headers.get("Range")
|
||||
video_size = os.path.getsize(video_path)
|
||||
start, end = 0, video_size - 1
|
||||
|
||||
length = video_size
|
||||
if range_header:
|
||||
range_ = range_header.split("bytes=")[1]
|
||||
start, end = [int(part) if part else None for part in range_.split("-")]
|
||||
if start is None:
|
||||
start = video_size - end
|
||||
end = video_size - 1
|
||||
if end is None:
|
||||
end = video_size - 1
|
||||
length = end - start + 1
|
||||
|
||||
def file_iterator(file_path, offset=0, bytes_to_read=None):
|
||||
with open(file_path, "rb") as f:
|
||||
f.seek(offset, os.SEEK_SET)
|
||||
remaining = bytes_to_read or video_size
|
||||
while remaining > 0:
|
||||
bytes_to_read = min(4096, remaining)
|
||||
data = f.read(bytes_to_read)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
|
||||
response = StreamingResponse(
|
||||
file_iterator(video_path, start, length), media_type="video/mp4"
|
||||
)
|
||||
response.headers["Content-Range"] = f"bytes {start}-{end}/{video_size}"
|
||||
response.headers["Accept-Ranges"] = "bytes"
|
||||
response.headers["Content-Length"] = str(length)
|
||||
response.status_code = 206 # Partial Content
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/download/{file_path:path}")
|
||||
async def download_video(request: Request, file_path: str):
|
||||
"""
|
||||
download video
|
||||
:param request: Request request
|
||||
:param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4
|
||||
:return: video file
|
||||
"""
|
||||
request_id = base.get_task_id(request)
|
||||
tasks_dir = utils.task_dir()
|
||||
video_path = _resolve_path_within_directory(tasks_dir, file_path, request_id)
|
||||
file_path = pathlib.Path(video_path)
|
||||
filename = file_path.stem
|
||||
extension = file_path.suffix
|
||||
headers = {"Content-Disposition": f"attachment; filename={filename}{extension}"}
|
||||
return FileResponse(
|
||||
path=video_path,
|
||||
headers=headers,
|
||||
filename=f"{filename}{extension}",
|
||||
media_type=f"video/{extension[1:]}",
|
||||
)
|
||||
Reference in New Issue
Block a user