mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-01-27 06:38:16 +00:00
commit
539dc7f27a
113
.dockerignore
113
.dockerignore
@ -1,24 +1,99 @@
|
||||
# Exclude common Python files and directories
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.pyz
|
||||
*.pyw
|
||||
*.pyi
|
||||
*.egg-info/
|
||||
|
||||
# Exclude development and local files
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
*.db
|
||||
|
||||
# Exclude version control system files
|
||||
# Git 相关
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.svn/
|
||||
|
||||
storage/
|
||||
# Python 相关
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# 虚拟环境
|
||||
.env
|
||||
.env.*
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE 相关
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 操作系统相关
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# 日志和数据库文件
|
||||
*.log
|
||||
*.db
|
||||
logs/
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
# 存储目录(运行时生成的内容)
|
||||
storage/temp/
|
||||
storage/tasks/
|
||||
storage/demo.py
|
||||
|
||||
# 缓存目录
|
||||
.cache/
|
||||
.pytest_cache/
|
||||
|
||||
# 文档(保留必要的)
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Docker 相关文件(避免递归复制)
|
||||
Dockerfile.*
|
||||
docker-compose.*.yml
|
||||
|
||||
# 配置文件(使用示例配置)
|
||||
config.toml
|
||||
|
||||
# 资源文件中的大文件
|
||||
resource/videos/
|
||||
resource/songs/
|
||||
|
||||
# 测试文件
|
||||
tests/
|
||||
test_*
|
||||
*_test.py
|
||||
|
||||
# 其他不必要的文件
|
||||
*.bak
|
||||
*.orig
|
||||
*.rej
|
||||
|
||||
94
Dockerfile
94
Dockerfile
@ -1,63 +1,97 @@
|
||||
# 构建阶段
|
||||
FROM python:3.10-slim-bullseye as builder
|
||||
# 多阶段构建 - 构建阶段
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
# 设置构建参数
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /build
|
||||
|
||||
# 安装构建依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
git \
|
||||
git-lfs \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 创建虚拟环境
|
||||
RUN python -m venv /opt/venv
|
||||
# 升级 pip 并创建虚拟环境
|
||||
RUN python -m pip install --upgrade pip setuptools wheel && \
|
||||
python -m venv /opt/venv
|
||||
|
||||
# 激活虚拟环境
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 首先安装 PyTorch(因为它是最大的依赖)
|
||||
RUN pip install --no-cache-dir torch torchvision torchaudio
|
||||
|
||||
# 然后安装其他依赖
|
||||
# 复制 requirements.txt 并安装 Python 依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 运行阶段
|
||||
FROM python:3.10-slim-bullseye
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# 设置运行参数
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /NarratoAI
|
||||
|
||||
# 从builder阶段复制虚拟环境
|
||||
# 从构建阶段复制虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 安装运行时依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# 设置环境变量
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
PYTHONPATH="/NarratoAI" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
|
||||
# 安装运行时系统依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
imagemagick \
|
||||
ffmpeg \
|
||||
wget \
|
||||
curl \
|
||||
git-lfs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& sed -i '/<policy domain="path" rights="none" pattern="@\*"/d' /etc/ImageMagick-6/policy.xml
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONPATH="/NarratoAI" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
# 配置 ImageMagick 策略(允许处理更多格式)
|
||||
RUN sed -i 's/<policy domain="path" rights="none" pattern="@\*"/<policy domain="path" rights="read|write" pattern="@\*"/' /etc/ImageMagick-6/policy.xml || true
|
||||
|
||||
# 设置目录权限
|
||||
RUN chmod 777 /NarratoAI
|
||||
|
||||
# 安装git lfs
|
||||
# 初始化 git-lfs
|
||||
RUN git lfs install
|
||||
|
||||
# 创建非 root 用户(安全最佳实践)
|
||||
RUN groupadd -r narratoai && useradd -r -g narratoai -d /NarratoAI -s /bin/bash narratoai
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
COPY --chown=narratoai:narratoai . .
|
||||
|
||||
# 确保配置文件存在
|
||||
RUN if [ ! -f config.toml ]; then cp config.example.toml config.toml; fi
|
||||
|
||||
# 创建必要的目录并设置权限
|
||||
RUN mkdir -p storage/temp storage/tasks storage/json storage/narration_scripts storage/drama_analysis && \
|
||||
chown -R narratoai:narratoai /NarratoAI && \
|
||||
chmod -R 755 /NarratoAI
|
||||
|
||||
# 复制并设置入口点脚本
|
||||
COPY --chown=narratoai:narratoai docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# 切换到非 root 用户
|
||||
USER narratoai
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8501 8080
|
||||
EXPOSE 8501
|
||||
|
||||
# 使用脚本作为入口点
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:8501/_stcore/health || exit 1
|
||||
|
||||
# 设置入口点
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["webui"]
|
||||
|
||||
63
Makefile
Normal file
63
Makefile
Normal file
@ -0,0 +1,63 @@
|
||||
# NarratoAI Docker Makefile
|
||||
|
||||
.PHONY: help build up down restart logs shell clean deploy
|
||||
|
||||
# 默认目标
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# 变量定义
|
||||
SERVICE_NAME := narratoai-webui
|
||||
|
||||
# 颜色定义
|
||||
GREEN := \033[32m
|
||||
YELLOW := \033[33m
|
||||
BLUE := \033[34m
|
||||
RESET := \033[0m
|
||||
|
||||
help: ## 显示帮助信息
|
||||
@echo "$(GREEN)NarratoAI Docker 管理命令$(RESET)"
|
||||
@echo ""
|
||||
@echo "$(YELLOW)可用命令:$(RESET)"
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " $(BLUE)%-15s$(RESET) %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
deploy: ## 一键部署
|
||||
@echo "$(GREEN)执行一键部署...$(RESET)"
|
||||
./docker-deploy.sh
|
||||
|
||||
build: ## 构建 Docker 镜像
|
||||
@echo "$(GREEN)构建 Docker 镜像...$(RESET)"
|
||||
docker-compose build
|
||||
|
||||
up: ## 启动服务
|
||||
@echo "$(GREEN)启动服务...$(RESET)"
|
||||
docker-compose up -d
|
||||
@echo "$(GREEN)访问地址: http://localhost:8501$(RESET)"
|
||||
|
||||
down: ## 停止服务
|
||||
@echo "$(YELLOW)停止服务...$(RESET)"
|
||||
docker-compose down
|
||||
|
||||
restart: ## 重启服务
|
||||
@echo "$(YELLOW)重启服务...$(RESET)"
|
||||
docker-compose restart
|
||||
|
||||
logs: ## 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
shell: ## 进入容器
|
||||
docker-compose exec $(SERVICE_NAME) bash
|
||||
|
||||
ps: ## 查看服务状态
|
||||
docker-compose ps
|
||||
|
||||
clean: ## 清理未使用的资源
|
||||
@echo "$(YELLOW)清理未使用的资源...$(RESET)"
|
||||
docker system prune -f
|
||||
|
||||
config: ## 检查配置文件
|
||||
@if [ -f "config.toml" ]; then \
|
||||
echo "$(GREEN)config.toml 存在$(RESET)"; \
|
||||
else \
|
||||
echo "$(YELLOW)复制示例配置...$(RESET)"; \
|
||||
cp config.example.toml config.toml; \
|
||||
fi
|
||||
90
app/asgi.py
90
app/asgi.py
@ -1,90 +0,0 @@
|
||||
"""Application implementation - ASGI."""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import config
|
||||
from app.models.exception import HttpException
|
||||
from app.router import root_api_router
|
||||
from app.utils import utils
|
||||
from app.utils import ffmpeg_utils
|
||||
|
||||
|
||||
def exception_handler(request: Request, e: HttpException):
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content=utils.get_response(e.status_code, e.data, e.message),
|
||||
)
|
||||
|
||||
|
||||
def validation_exception_handler(request: Request, e: RequestValidationError):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=utils.get_response(
|
||||
status=400, data=e.errors(), message="field required"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_application() -> FastAPI:
|
||||
"""Initialize FastAPI application.
|
||||
|
||||
Returns:
|
||||
FastAPI: Application object instance.
|
||||
|
||||
"""
|
||||
instance = FastAPI(
|
||||
title=config.project_name,
|
||||
description=config.project_description,
|
||||
version=config.project_version,
|
||||
debug=False,
|
||||
)
|
||||
instance.include_router(root_api_router)
|
||||
instance.add_exception_handler(HttpException, exception_handler)
|
||||
instance.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
return instance
|
||||
|
||||
|
||||
app = get_application()
|
||||
|
||||
# Configures the CORS middleware for the FastAPI app
|
||||
cors_allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "")
|
||||
origins = cors_allowed_origins_str.split(",") if cors_allowed_origins_str else ["*"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
task_dir = utils.task_dir()
|
||||
app.mount(
|
||||
"/tasks", StaticFiles(directory=task_dir, html=True, follow_symlink=True), name=""
|
||||
)
|
||||
|
||||
public_dir = utils.public_dir()
|
||||
app.mount("/", StaticFiles(directory=public_dir, html=True), name="")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown_event():
|
||||
logger.info("shutdown event")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
logger.info("startup event")
|
||||
|
||||
# 检测FFmpeg硬件加速
|
||||
hwaccel_info = ffmpeg_utils.detect_hardware_acceleration()
|
||||
if hwaccel_info["available"]:
|
||||
logger.info(f"FFmpeg硬件加速检测结果: 可用 | 类型: {hwaccel_info['type']} | 编码器: {hwaccel_info['encoder']} | 独立显卡: {hwaccel_info['is_dedicated_gpu']} | 参数: {hwaccel_info['hwaccel_args']}")
|
||||
else:
|
||||
logger.warning(f"FFmpeg硬件加速不可用: {hwaccel_info['message']}, 将使用CPU软件编码")
|
||||
@ -1,31 +0,0 @@
|
||||
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}",
|
||||
)
|
||||
@ -1,64 +0,0 @@
|
||||
import threading
|
||||
from typing import Callable, Any, Dict
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self, max_concurrent_tasks: int):
|
||||
self.max_concurrent_tasks = max_concurrent_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:
|
||||
print(f"add task: {func.__name__}, current_tasks: {self.current_tasks}")
|
||||
self.execute_task(func, *args, **kwargs)
|
||||
else:
|
||||
print(
|
||||
f"enqueue task: {func.__name__}, current_tasks: {self.current_tasks}"
|
||||
)
|
||||
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) # 在这里调用函数,传递*args和**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()
|
||||
@ -1,18 +0,0 @@
|
||||
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()
|
||||
|
||||
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()
|
||||
@ -1,56 +0,0 @@
|
||||
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):
|
||||
self.redis_client = redis.Redis.from_url(redis_url)
|
||||
super().__init__(max_concurrent_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
|
||||
@ -1,14 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Request
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ping",
|
||||
tags=["Health Check"],
|
||||
description="检查服务可用性",
|
||||
response_description="pong",
|
||||
)
|
||||
def ping(request: Request) -> str:
|
||||
return "pong"
|
||||
@ -1,11 +0,0 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
|
||||
def new_router(dependencies=None):
|
||||
router = APIRouter()
|
||||
router.tags = ["V1"]
|
||||
router.prefix = "/api/v1"
|
||||
# 将认证依赖项应用于所有路由
|
||||
if dependencies:
|
||||
router.dependencies = dependencies
|
||||
return router
|
||||
@ -1,93 +0,0 @@
|
||||
from fastapi import Request, File, UploadFile
|
||||
import os
|
||||
from app.controllers.v1.base import new_router
|
||||
from app.models.schema import (
|
||||
VideoScriptResponse,
|
||||
VideoScriptRequest,
|
||||
VideoTermsResponse,
|
||||
VideoTermsRequest,
|
||||
VideoTranscriptionRequest,
|
||||
VideoTranscriptionResponse,
|
||||
)
|
||||
from app.services import llm
|
||||
from app.utils import utils
|
||||
from app.config import config
|
||||
|
||||
# 认证依赖项
|
||||
# router = new_router(dependencies=[Depends(base.verify_token)])
|
||||
router = new_router()
|
||||
|
||||
# 定义上传目录
|
||||
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads")
|
||||
|
||||
@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,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/transcription",
|
||||
response_model=VideoTranscriptionResponse,
|
||||
summary="Transcribe video content using Gemini"
|
||||
)
|
||||
async def transcribe_video(
|
||||
request: Request,
|
||||
video_name: str,
|
||||
language: str = "zh-CN",
|
||||
video_file: UploadFile = File(...)
|
||||
):
|
||||
"""
|
||||
使用 Gemini 转录视频内容,包括时间戳、画面描述和语音内容
|
||||
|
||||
Args:
|
||||
video_name: 视频名称
|
||||
language: 语言代码,默认zh-CN
|
||||
video_file: 上传的视频文件
|
||||
"""
|
||||
# 创建临时目录用于存储上传的视频
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
# 保存上传的视频文件
|
||||
video_path = os.path.join(UPLOAD_DIR, video_file.filename)
|
||||
with open(video_path, "wb") as buffer:
|
||||
content = await video_file.read()
|
||||
buffer.write(content)
|
||||
|
||||
try:
|
||||
transcription = llm.gemini_video_transcription(
|
||||
video_name=video_name,
|
||||
video_path=video_path,
|
||||
language=language,
|
||||
llm_provider_video=config.app.get("video_llm_provider", "gemini")
|
||||
)
|
||||
response = {"transcription": transcription}
|
||||
return utils.get_response(200, response)
|
||||
finally:
|
||||
# 处理完成后删除临时文件
|
||||
if os.path.exists(video_path):
|
||||
os.remove(video_path)
|
||||
@ -1,271 +0,0 @@
|
||||
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.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,
|
||||
)
|
||||
from app.services import state as sm
|
||||
from app.services import task as tm
|
||||
from app.utils import 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)
|
||||
|
||||
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
|
||||
)
|
||||
else:
|
||||
task_manager = InMemoryTaskManager(max_concurrent_tasks=_max_concurrent_tasks)
|
||||
|
||||
|
||||
@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 ValueError as e:
|
||||
raise HttpException(
|
||||
task_id=task_id, status_code=400, message=f"{request_id}: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@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(),
|
||||
):
|
||||
endpoint = config.app.get("endpoint", "")
|
||||
if not endpoint:
|
||||
endpoint = str(request.base_url)
|
||||
endpoint = endpoint.rstrip("/")
|
||||
|
||||
request_id = base.get_task_id(request)
|
||||
task = sm.state.get_task(task_id)
|
||||
if task:
|
||||
task_dir = utils.task_dir()
|
||||
|
||||
def file_to_uri(file):
|
||||
if not file.startswith(endpoint):
|
||||
_uri_path = v.replace(task_dir, "tasks").replace("\\", "/")
|
||||
_uri_path = f"{endpoint}/{_uri_path}"
|
||||
else:
|
||||
_uri_path = file
|
||||
return _uri_path
|
||||
|
||||
if "videos" in task:
|
||||
videos = task["videos"]
|
||||
urls = []
|
||||
for v in videos:
|
||||
urls.append(file_to_uri(v))
|
||||
task["videos"] = urls
|
||||
if "combined_videos" in task:
|
||||
combined_videos = task["combined_videos"]
|
||||
urls = []
|
||||
for v in combined_videos:
|
||||
urls.append(file_to_uri(v))
|
||||
task["combined_videos"] = urls
|
||||
return utils.get_response(200, 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:
|
||||
# bgm_list.append(
|
||||
# {
|
||||
# "name": os.path.basename(file),
|
||||
# "size": os.path.getsize(file),
|
||||
# "file": file,
|
||||
# }
|
||||
# )
|
||||
# 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)
|
||||
# # check file ext
|
||||
# if file.filename.endswith("mp3"):
|
||||
# song_dir = utils.song_dir()
|
||||
# save_path = os.path.join(song_dir, file.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": save_path}
|
||||
# return utils.get_response(200, response)
|
||||
#
|
||||
# raise HttpException(
|
||||
# "", status_code=400, message=f"{request_id}: Only *.mp3 files can be uploaded"
|
||||
# )
|
||||
#
|
||||
#
|
||||
# @router.get("/stream/{file_path:path}")
|
||||
# async def stream_video(request: Request, file_path: str):
|
||||
# tasks_dir = utils.task_dir()
|
||||
# video_path = os.path.join(tasks_dir, file_path)
|
||||
# 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, file_path: str):
|
||||
# """
|
||||
# download video
|
||||
# :param _: Request request
|
||||
# :param file_path: video file path, eg: /cd1727ed-3473-42a2-a7da-4faafafec72b/final-1.mp4
|
||||
# :return: video file
|
||||
# """
|
||||
# tasks_dir = utils.task_dir()
|
||||
# video_path = os.path.join(tasks_dir, file_path)
|
||||
# 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:]}",
|
||||
# )
|
||||
@ -1,11 +0,0 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
|
||||
def v2_router(dependencies=None):
|
||||
router = APIRouter()
|
||||
router.tags = ["V2"]
|
||||
router.prefix = "/api/v2"
|
||||
# 将认证依赖项应用于所有路由
|
||||
if dependencies:
|
||||
router.dependencies = dependencies
|
||||
return router
|
||||
@ -1,170 +0,0 @@
|
||||
from fastapi import APIRouter, BackgroundTasks
|
||||
from loguru import logger
|
||||
import os
|
||||
|
||||
from app.models.schema_v2 import (
|
||||
GenerateScriptRequest,
|
||||
GenerateScriptResponse,
|
||||
CropVideoRequest,
|
||||
CropVideoResponse,
|
||||
DownloadVideoRequest,
|
||||
DownloadVideoResponse,
|
||||
StartSubclipRequest,
|
||||
StartSubclipResponse
|
||||
)
|
||||
from app.models.schema import VideoClipParams
|
||||
from app.services.script_service import ScriptGenerator
|
||||
from app.services.video_service import VideoService
|
||||
from app.utils import utils
|
||||
from app.controllers.v2.base import v2_router
|
||||
from app.models.schema import VideoClipParams
|
||||
from app.services.youtube_service import YoutubeService
|
||||
from app.services import task as task_service
|
||||
|
||||
router = v2_router()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scripts/generate",
|
||||
response_model=GenerateScriptResponse,
|
||||
summary="同步请求;生成视频脚本 (V2)"
|
||||
)
|
||||
async def generate_script(
|
||||
request: GenerateScriptRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
生成视频脚本的V2版本API
|
||||
"""
|
||||
task_id = utils.get_uuid()
|
||||
|
||||
try:
|
||||
generator = ScriptGenerator()
|
||||
script = await generator.generate_script(
|
||||
video_path=request.video_path,
|
||||
video_theme=request.video_theme,
|
||||
custom_prompt=request.custom_prompt,
|
||||
skip_seconds=request.skip_seconds,
|
||||
threshold=request.threshold,
|
||||
vision_batch_size=request.vision_batch_size,
|
||||
vision_llm_provider=request.vision_llm_provider
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"script": script
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Generate script failed: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scripts/crop",
|
||||
response_model=CropVideoResponse,
|
||||
summary="同步请求;裁剪视频 (V2)"
|
||||
)
|
||||
async def crop_video(
|
||||
request: CropVideoRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
根据脚本裁剪视频的V2版本API
|
||||
"""
|
||||
try:
|
||||
# 调用视频裁剪服务
|
||||
video_service = VideoService()
|
||||
task_id, subclip_videos = await video_service.crop_video(
|
||||
video_path=request.video_origin_path,
|
||||
video_script=request.video_script
|
||||
)
|
||||
logger.debug(f"裁剪视频成功,视频片段路径: {subclip_videos}")
|
||||
logger.debug(type(subclip_videos))
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"subclip_videos": subclip_videos
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Crop video failed: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/youtube/download",
|
||||
response_model=DownloadVideoResponse,
|
||||
summary="同步请求;下载YouTube视频 (V2)"
|
||||
)
|
||||
async def download_youtube_video(
|
||||
request: DownloadVideoRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
下载指定分辨率的YouTube视频
|
||||
"""
|
||||
try:
|
||||
youtube_service = YoutubeService()
|
||||
task_id, output_path, filename = await youtube_service.download_video(
|
||||
url=request.url,
|
||||
resolution=request.resolution,
|
||||
output_format=request.output_format,
|
||||
rename=request.rename
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"output_path": output_path,
|
||||
"resolution": request.resolution,
|
||||
"format": request.output_format,
|
||||
"filename": filename
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Download YouTube video failed: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/scripts/start-subclip",
|
||||
response_model=StartSubclipResponse,
|
||||
summary="异步请求;开始视频剪辑任务 (V2)"
|
||||
)
|
||||
async def start_subclip(
|
||||
request: VideoClipParams,
|
||||
task_id: str,
|
||||
subclip_videos: dict,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
开始视频剪辑任务的V2版本API
|
||||
"""
|
||||
try:
|
||||
# 构建参数对象
|
||||
params = VideoClipParams(
|
||||
video_origin_path=request.video_origin_path,
|
||||
video_clip_json_path=request.video_clip_json_path,
|
||||
voice_name=request.voice_name,
|
||||
voice_rate=request.voice_rate,
|
||||
voice_pitch=request.voice_pitch,
|
||||
subtitle_enabled=request.subtitle_enabled,
|
||||
video_aspect=request.video_aspect,
|
||||
n_threads=request.n_threads
|
||||
)
|
||||
|
||||
# 在后台任务中执行视频剪辑
|
||||
background_tasks.add_task(
|
||||
task_service.start_subclip,
|
||||
task_id=task_id,
|
||||
params=params,
|
||||
subclip_path_videos=subclip_videos
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"state": "PROCESSING" # 初始状态
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Start subclip task failed: {str(e)}")
|
||||
raise
|
||||
@ -154,203 +154,8 @@ class VideoParams(BaseModel):
|
||||
paragraph_number: Optional[int] = 1
|
||||
|
||||
|
||||
class SubtitleRequest(BaseModel):
|
||||
video_script: str
|
||||
video_language: Optional[str] = ""
|
||||
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
|
||||
voice_volume: Optional[float] = 1.0
|
||||
voice_rate: Optional[float] = 1.2
|
||||
bgm_type: Optional[str] = "random"
|
||||
bgm_file: Optional[str] = ""
|
||||
bgm_volume: Optional[float] = 0.2
|
||||
subtitle_position: Optional[str] = "bottom"
|
||||
font_name: Optional[str] = "STHeitiMedium.ttc"
|
||||
text_fore_color: Optional[str] = "#FFFFFF"
|
||||
text_background_color: Optional[str] = "transparent"
|
||||
font_size: int = 60
|
||||
stroke_color: Optional[str] = "#000000"
|
||||
stroke_width: float = 1.5
|
||||
video_source: Optional[str] = "local"
|
||||
subtitle_enabled: Optional[str] = "true"
|
||||
|
||||
|
||||
class AudioRequest(BaseModel):
|
||||
video_script: str
|
||||
video_language: Optional[str] = ""
|
||||
voice_name: Optional[str] = "zh-CN-XiaoxiaoNeural-Female"
|
||||
voice_volume: Optional[float] = AudioVolumeDefaults.VOICE_VOLUME
|
||||
voice_rate: Optional[float] = 1.2
|
||||
bgm_type: Optional[str] = "random"
|
||||
bgm_file: Optional[str] = ""
|
||||
bgm_volume: Optional[float] = AudioVolumeDefaults.BGM_VOLUME
|
||||
video_source: Optional[str] = "local"
|
||||
|
||||
|
||||
class VideoScriptParams:
|
||||
"""
|
||||
{
|
||||
"video_subject": "春天的花海",
|
||||
"video_language": "",
|
||||
"paragraph_number": 1
|
||||
}
|
||||
"""
|
||||
|
||||
video_subject: Optional[str] = "春天的花海"
|
||||
video_language: Optional[str] = ""
|
||||
paragraph_number: Optional[int] = 1
|
||||
|
||||
|
||||
class VideoTermsParams:
|
||||
"""
|
||||
{
|
||||
"video_subject": "",
|
||||
"video_script": "",
|
||||
"amount": 5
|
||||
}
|
||||
"""
|
||||
|
||||
video_subject: Optional[str] = "春天的花海"
|
||||
video_script: Optional[str] = (
|
||||
"春天的花海,如诗如画般展现在眼前。万物复苏的季节里,大地披上了一袭绚丽多彩的盛装。金黄的迎春、粉嫩的樱花、洁白的梨花、艳丽的郁金香……"
|
||||
)
|
||||
amount: Optional[int] = 5
|
||||
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
status: int = 200
|
||||
message: Optional[str] = "success"
|
||||
data: Any = None
|
||||
|
||||
|
||||
class TaskVideoRequest(VideoParams, BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class TaskQueryRequest(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class VideoScriptRequest(VideoScriptParams, BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class VideoTermsRequest(VideoTermsParams, BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
######################################################################################################
|
||||
class TaskResponse(BaseResponse):
|
||||
class TaskResponseData(BaseModel):
|
||||
task_id: str
|
||||
|
||||
data: TaskResponseData
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {"task_id": "6c85c8cc-a77a-42b9-bc30-947815aa0558"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TaskQueryResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"state": 1,
|
||||
"progress": 100,
|
||||
"videos": [
|
||||
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4"
|
||||
],
|
||||
"combined_videos": [
|
||||
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TaskDeletionResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"state": 1,
|
||||
"progress": 100,
|
||||
"videos": [
|
||||
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/final-1.mp4"
|
||||
],
|
||||
"combined_videos": [
|
||||
"http://127.0.0.1:8080/tasks/6c85c8cc-a77a-42b9-bc30-947815aa0558/combined-1.mp4"
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VideoScriptResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"video_script": "春天的花海,是大自然的一幅美丽画卷。在这个季节里,大地复苏,万物生长,花朵争相绽放,形成了一片五彩斑斓的花海..."
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VideoTermsResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {"video_terms": ["sky", "tree"]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class BgmRetrieveResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"files": [
|
||||
{
|
||||
"name": "output013.mp3",
|
||||
"size": 1891269,
|
||||
"file": "/NarratoAI/resource/songs/output013.mp3",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class BgmUploadResponse(BaseResponse):
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"status": 200,
|
||||
"message": "success",
|
||||
"data": {"file": "/NarratoAI/resource/songs/example.mp3"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VideoClipParams(BaseModel):
|
||||
"""
|
||||
@ -393,16 +198,7 @@ class VideoClipParams(BaseModel):
|
||||
bgm_volume: Optional[float] = Field(default=AudioVolumeDefaults.BGM_VOLUME, description="背景音乐音量")
|
||||
|
||||
|
||||
class VideoTranscriptionRequest(BaseModel):
|
||||
video_name: str
|
||||
language: str = "zh-CN"
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class VideoTranscriptionResponse(BaseModel):
|
||||
transcription: str
|
||||
|
||||
|
||||
class SubtitlePosition(str, Enum):
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GenerateScriptRequest(BaseModel):
|
||||
video_path: str
|
||||
video_theme: Optional[str] = ""
|
||||
custom_prompt: Optional[str] = ""
|
||||
frame_interval_input: Optional[int] = 5
|
||||
skip_seconds: Optional[int] = 0
|
||||
threshold: Optional[int] = 30
|
||||
vision_batch_size: Optional[int] = 5
|
||||
vision_llm_provider: Optional[str] = "gemini"
|
||||
|
||||
|
||||
class GenerateScriptResponse(BaseModel):
|
||||
task_id: str
|
||||
script: List[dict]
|
||||
|
||||
|
||||
class CropVideoRequest(BaseModel):
|
||||
video_origin_path: str
|
||||
video_script: List[dict]
|
||||
|
||||
|
||||
class CropVideoResponse(BaseModel):
|
||||
task_id: str
|
||||
subclip_videos: dict
|
||||
|
||||
|
||||
class DownloadVideoRequest(BaseModel):
|
||||
url: str
|
||||
resolution: str
|
||||
output_format: Optional[str] = "mp4"
|
||||
rename: Optional[str] = None
|
||||
|
||||
|
||||
class DownloadVideoResponse(BaseModel):
|
||||
task_id: str
|
||||
output_path: str
|
||||
resolution: str
|
||||
format: str
|
||||
filename: str
|
||||
|
||||
|
||||
class StartSubclipRequest(BaseModel):
|
||||
task_id: str
|
||||
video_origin_path: str
|
||||
video_clip_json_path: str
|
||||
voice_name: Optional[str] = None
|
||||
voice_rate: Optional[int] = 0
|
||||
voice_pitch: Optional[int] = 0
|
||||
subtitle_enabled: Optional[bool] = True
|
||||
video_aspect: Optional[str] = "16:9"
|
||||
n_threads: Optional[int] = 4
|
||||
subclip_videos: list # 从裁剪视频接口获取的视频片段字典
|
||||
|
||||
|
||||
class StartSubclipResponse(BaseModel):
|
||||
task_id: str
|
||||
state: str
|
||||
videos: Optional[List[str]] = None
|
||||
combined_videos: Optional[List[str]] = None
|
||||
@ -1,21 +0,0 @@
|
||||
"""Application configuration - root APIRouter.
|
||||
|
||||
Defines all FastAPI application endpoints.
|
||||
|
||||
Resources:
|
||||
1. https://fastapi.tiangolo.com/tutorial/bigger-applications
|
||||
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.controllers.v1 import llm, video
|
||||
from app.controllers.v2 import script
|
||||
|
||||
root_api_router = APIRouter()
|
||||
# v1
|
||||
root_api_router.include_router(video.router)
|
||||
root_api_router.include_router(llm.router)
|
||||
|
||||
# v2
|
||||
root_api_router.include_router(script.router)
|
||||
@ -29,6 +29,40 @@ from app.models.schema import AudioVolumeDefaults
|
||||
from app.services.audio_normalizer import AudioNormalizer, normalize_audio_for_mixing
|
||||
|
||||
|
||||
def is_valid_subtitle_file(subtitle_path: str) -> bool:
|
||||
"""
|
||||
检查字幕文件是否有效
|
||||
|
||||
参数:
|
||||
subtitle_path: 字幕文件路径
|
||||
|
||||
返回:
|
||||
bool: 如果字幕文件存在且包含有效内容则返回True,否则返回False
|
||||
"""
|
||||
if not subtitle_path or not os.path.exists(subtitle_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(subtitle_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
|
||||
# 检查文件是否为空
|
||||
if not content:
|
||||
return False
|
||||
|
||||
# 检查是否包含时间戳格式(SRT格式的基本特征)
|
||||
# SRT格式应该包含类似 "00:00:00,000 --> 00:00:00,000" 的时间戳
|
||||
import re
|
||||
time_pattern = r'\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}'
|
||||
if not re.search(time_pattern, content):
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"检查字幕文件时出错: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def merge_materials(
|
||||
video_path: str,
|
||||
audio_path: str,
|
||||
@ -137,8 +171,12 @@ def merge_materials(
|
||||
try:
|
||||
original_audio = video_clip.audio
|
||||
if original_audio:
|
||||
original_audio = original_audio.with_effects([afx.MultiplyVolume(original_audio_volume)])
|
||||
logger.info(f"已提取视频原声,音量设置为: {original_audio_volume}")
|
||||
# 关键修复:只有当音量不为1.0时才进行音量调整,保持原声音量不变
|
||||
if abs(original_audio_volume - 1.0) > 0.001: # 使用小的容差值比较浮点数
|
||||
original_audio = original_audio.with_effects([afx.MultiplyVolume(original_audio_volume)])
|
||||
logger.info(f"已提取视频原声,音量调整为: {original_audio_volume}")
|
||||
else:
|
||||
logger.info("已提取视频原声,保持原始音量不变")
|
||||
else:
|
||||
logger.warning("视频没有音轨,无法提取原声")
|
||||
except Exception as e:
|
||||
@ -314,34 +352,36 @@ def merge_materials(
|
||||
color=subtitle_color,
|
||||
)
|
||||
|
||||
# 处理字幕 - 修复字幕开关bug
|
||||
if subtitle_enabled and subtitle_path and os.path.exists(subtitle_path):
|
||||
logger.info("字幕已启用,开始处理字幕文件")
|
||||
try:
|
||||
# 加载字幕文件
|
||||
sub = SubtitlesClip(
|
||||
subtitles=subtitle_path,
|
||||
encoding="utf-8",
|
||||
make_textclip=make_textclip
|
||||
)
|
||||
# 处理字幕 - 修复字幕开关bug和空字幕文件问题
|
||||
if subtitle_enabled and subtitle_path:
|
||||
if is_valid_subtitle_file(subtitle_path):
|
||||
logger.info("字幕已启用,开始处理字幕文件")
|
||||
try:
|
||||
# 加载字幕文件
|
||||
sub = SubtitlesClip(
|
||||
subtitles=subtitle_path,
|
||||
encoding="utf-8",
|
||||
make_textclip=make_textclip
|
||||
)
|
||||
|
||||
# 创建每个字幕片段
|
||||
text_clips = []
|
||||
for item in sub.subtitles:
|
||||
clip = create_text_clip(subtitle_item=item)
|
||||
text_clips.append(clip)
|
||||
# 创建每个字幕片段
|
||||
text_clips = []
|
||||
for item in sub.subtitles:
|
||||
clip = create_text_clip(subtitle_item=item)
|
||||
text_clips.append(clip)
|
||||
|
||||
# 合成视频和字幕
|
||||
video_clip = CompositeVideoClip([video_clip, *text_clips])
|
||||
logger.info(f"已添加{len(text_clips)}个字幕片段")
|
||||
except Exception as e:
|
||||
logger.error(f"处理字幕失败: \n{traceback.format_exc()}")
|
||||
# 合成视频和字幕
|
||||
video_clip = CompositeVideoClip([video_clip, *text_clips])
|
||||
logger.info(f"已添加{len(text_clips)}个字幕片段")
|
||||
except Exception as e:
|
||||
logger.error(f"处理字幕失败: \n{traceback.format_exc()}")
|
||||
logger.warning("字幕处理失败,继续生成无字幕视频")
|
||||
else:
|
||||
logger.warning(f"字幕文件无效或为空: {subtitle_path},跳过字幕处理")
|
||||
elif not subtitle_enabled:
|
||||
logger.info("字幕已禁用,跳过字幕处理")
|
||||
elif not subtitle_path:
|
||||
logger.info("未提供字幕文件路径,跳过字幕处理")
|
||||
elif not os.path.exists(subtitle_path):
|
||||
logger.warning(f"字幕文件不存在: {subtitle_path},跳过字幕处理")
|
||||
|
||||
# 导出最终视频
|
||||
try:
|
||||
|
||||
@ -566,9 +566,14 @@ def combine_clip_videos(
|
||||
with open(filter_script, 'w') as f:
|
||||
f.write(f"[0:a]volume=0.0[silence];\n") # 首先静音背景轨道
|
||||
|
||||
# 添加每个音频文件
|
||||
# 添加每个音频文件,并补偿amix的音量稀释
|
||||
# amix会将n个输入的音量平均分配,所以我们需要将每个输入的音量提高n倍来保持原始音量
|
||||
num_inputs = len(audio_timings) + 1 # +1 for silence track
|
||||
volume_compensation = num_inputs # 补偿系数
|
||||
|
||||
for i, timing in enumerate(audio_timings):
|
||||
f.write(f"[{i+1}:a]adelay={int(timing['start']*1000)}|{int(timing['start']*1000)}[a{i}];\n")
|
||||
# 为每个音频添加音量补偿,确保原声保持原始音量
|
||||
f.write(f"[{i+1}:a]volume={volume_compensation},adelay={int(timing['start']*1000)}|{int(timing['start']*1000)}[a{i}];\n")
|
||||
|
||||
# 混合所有音频
|
||||
mix_str = "[silence]"
|
||||
|
||||
@ -62,91 +62,127 @@ def parse_edited_time_range(time_range_str):
|
||||
def merge_subtitle_files(subtitle_items, output_file=None):
|
||||
"""
|
||||
合并多个SRT字幕文件
|
||||
|
||||
|
||||
参数:
|
||||
subtitle_items: 字典列表,每个字典包含subtitle文件路径和editedTimeRange
|
||||
output_file: 输出文件的路径,如果为None则自动生成
|
||||
|
||||
|
||||
返回:
|
||||
合并后的字幕文件路径
|
||||
合并后的字幕文件路径,如果没有有效字幕则返回None
|
||||
"""
|
||||
# 按照editedTimeRange的开始时间排序
|
||||
sorted_items = sorted(subtitle_items,
|
||||
sorted_items = sorted(subtitle_items,
|
||||
key=lambda x: parse_edited_time_range(x.get('editedTimeRange', ''))[0] or timedelta())
|
||||
|
||||
|
||||
merged_subtitles = []
|
||||
subtitle_index = 1
|
||||
|
||||
valid_items_count = 0
|
||||
|
||||
for item in sorted_items:
|
||||
if not item.get('subtitle') or not os.path.exists(item.get('subtitle')):
|
||||
print(f"跳过项目 {item.get('_id')}:字幕文件不存在或路径为空")
|
||||
continue
|
||||
|
||||
|
||||
# 从editedTimeRange获取起始时间偏移
|
||||
offset_time, _ = parse_edited_time_range(item.get('editedTimeRange', ''))
|
||||
|
||||
|
||||
if offset_time is None:
|
||||
print(f"警告: 无法从项目 {item.get('_id')} 的editedTimeRange中提取时间范围,跳过该项")
|
||||
continue
|
||||
|
||||
with open(item['subtitle'], 'r', encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
|
||||
# 解析字幕文件
|
||||
subtitle_blocks = re.split(r'\n\s*\n', content.strip())
|
||||
|
||||
for block in subtitle_blocks:
|
||||
lines = block.strip().split('\n')
|
||||
if len(lines) < 3: # 确保块有足够的行数
|
||||
|
||||
try:
|
||||
with open(item['subtitle'], 'r', encoding='utf-8') as file:
|
||||
content = file.read().strip()
|
||||
|
||||
# 检查文件内容是否为空
|
||||
if not content:
|
||||
print(f"跳过项目 {item.get('_id')}:字幕文件内容为空")
|
||||
continue
|
||||
|
||||
# 解析时间轴行
|
||||
time_line = lines[1]
|
||||
time_parts = time_line.split(' --> ')
|
||||
if len(time_parts) != 2:
|
||||
continue
|
||||
|
||||
start_time = parse_time(time_parts[0])
|
||||
end_time = parse_time(time_parts[1])
|
||||
|
||||
# 应用时间偏移
|
||||
adjusted_start_time = start_time + offset_time
|
||||
adjusted_end_time = end_time + offset_time
|
||||
|
||||
# 重建字幕块
|
||||
adjusted_time_line = f"{format_time(adjusted_start_time)} --> {format_time(adjusted_end_time)}"
|
||||
text_lines = lines[2:]
|
||||
|
||||
new_block = [
|
||||
str(subtitle_index),
|
||||
adjusted_time_line,
|
||||
*text_lines
|
||||
]
|
||||
|
||||
merged_subtitles.append('\n'.join(new_block))
|
||||
subtitle_index += 1
|
||||
|
||||
|
||||
valid_items_count += 1
|
||||
|
||||
# 解析字幕文件
|
||||
subtitle_blocks = re.split(r'\n\s*\n', content)
|
||||
|
||||
for block in subtitle_blocks:
|
||||
lines = block.strip().split('\n')
|
||||
if len(lines) < 3: # 确保块有足够的行数
|
||||
continue
|
||||
|
||||
# 解析时间轴行
|
||||
time_line = lines[1]
|
||||
time_parts = time_line.split(' --> ')
|
||||
if len(time_parts) != 2:
|
||||
continue
|
||||
|
||||
start_time = parse_time(time_parts[0])
|
||||
end_time = parse_time(time_parts[1])
|
||||
|
||||
# 应用时间偏移
|
||||
adjusted_start_time = start_time + offset_time
|
||||
adjusted_end_time = end_time + offset_time
|
||||
|
||||
# 重建字幕块
|
||||
adjusted_time_line = f"{format_time(adjusted_start_time)} --> {format_time(adjusted_end_time)}"
|
||||
text_lines = lines[2:]
|
||||
|
||||
new_block = [
|
||||
str(subtitle_index),
|
||||
adjusted_time_line,
|
||||
*text_lines
|
||||
]
|
||||
|
||||
merged_subtitles.append('\n'.join(new_block))
|
||||
subtitle_index += 1
|
||||
except Exception as e:
|
||||
print(f"处理项目 {item.get('_id')} 的字幕文件时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
# 检查是否有有效的字幕内容
|
||||
if not merged_subtitles:
|
||||
print(f"警告: 没有找到有效的字幕内容,共检查了 {len(subtitle_items)} 个项目,其中 {valid_items_count} 个有有效文件")
|
||||
return None
|
||||
|
||||
# 确定输出文件路径
|
||||
if output_file is None:
|
||||
dir_path = os.path.dirname(sorted_items[0]['subtitle'])
|
||||
# 找到第一个有效的字幕文件来确定目录
|
||||
valid_item = None
|
||||
for item in sorted_items:
|
||||
if item.get('subtitle') and os.path.exists(item.get('subtitle')):
|
||||
valid_item = item
|
||||
break
|
||||
|
||||
if not valid_item:
|
||||
print("错误: 无法确定输出目录,没有找到有效的字幕文件")
|
||||
return None
|
||||
|
||||
dir_path = os.path.dirname(valid_item['subtitle'])
|
||||
first_start = parse_edited_time_range(sorted_items[0]['editedTimeRange'])[0]
|
||||
last_end = parse_edited_time_range(sorted_items[-1]['editedTimeRange'])[1]
|
||||
|
||||
first_start_h, first_start_m, first_start_s = int(first_start.seconds // 3600), int((first_start.seconds % 3600) // 60), int(first_start.seconds % 60)
|
||||
last_end_h, last_end_m, last_end_s = int(last_end.seconds // 3600), int((last_end.seconds % 3600) // 60), int(last_end.seconds % 60)
|
||||
|
||||
first_start_str = f"{first_start_h:02d}_{first_start_m:02d}_{first_start_s:02d}"
|
||||
last_end_str = f"{last_end_h:02d}_{last_end_m:02d}_{last_end_s:02d}"
|
||||
|
||||
output_file = os.path.join(dir_path, f"merged_subtitle_{first_start_str}-{last_end_str}.srt")
|
||||
|
||||
|
||||
if first_start and last_end:
|
||||
first_start_h, first_start_m, first_start_s = int(first_start.seconds // 3600), int((first_start.seconds % 3600) // 60), int(first_start.seconds % 60)
|
||||
last_end_h, last_end_m, last_end_s = int(last_end.seconds // 3600), int((last_end.seconds % 3600) // 60), int(last_end.seconds % 60)
|
||||
|
||||
first_start_str = f"{first_start_h:02d}_{first_start_m:02d}_{first_start_s:02d}"
|
||||
last_end_str = f"{last_end_h:02d}_{last_end_m:02d}_{last_end_s:02d}"
|
||||
|
||||
output_file = os.path.join(dir_path, f"merged_subtitle_{first_start_str}-{last_end_str}.srt")
|
||||
else:
|
||||
output_file = os.path.join(dir_path, f"merged_subtitle.srt")
|
||||
|
||||
# 合并所有字幕块
|
||||
merged_content = '\n\n'.join(merged_subtitles)
|
||||
|
||||
|
||||
# 写入合并后的内容
|
||||
with open(output_file, 'w', encoding='utf-8') as file:
|
||||
file.write(merged_content)
|
||||
|
||||
return output_file
|
||||
try:
|
||||
with open(output_file, 'w', encoding='utf-8') as file:
|
||||
file.write(merged_content)
|
||||
print(f"字幕文件合并成功: {output_file},包含 {len(merged_subtitles)} 个字幕条目")
|
||||
return output_file
|
||||
except Exception as e:
|
||||
print(f"写入字幕文件失败: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@ -136,11 +136,21 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
|
||||
list_script=new_script_list
|
||||
)
|
||||
logger.info(f"音频文件合并成功->{merged_audio_path}")
|
||||
|
||||
# 合并字幕文件
|
||||
merged_subtitle_path = subtitle_merger.merge_subtitle_files(new_script_list)
|
||||
logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
|
||||
if merged_subtitle_path:
|
||||
logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
|
||||
else:
|
||||
logger.warning("没有有效的字幕内容,将生成无字幕视频")
|
||||
merged_subtitle_path = ""
|
||||
except Exception as e:
|
||||
logger.error(f"合并音频文件失败: {str(e)}")
|
||||
logger.error(f"合并音频/字幕文件失败: {str(e)}")
|
||||
# 确保即使合并失败也有默认值
|
||||
if 'merged_audio_path' not in locals():
|
||||
merged_audio_path = ""
|
||||
if 'merged_subtitle_path' not in locals():
|
||||
merged_subtitle_path = ""
|
||||
else:
|
||||
logger.warning("没有需要合并的音频/字幕")
|
||||
merged_audio_path = ""
|
||||
@ -197,10 +207,20 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: di
|
||||
# 获取优化的音量配置
|
||||
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
||||
|
||||
# 检查是否有OST=1的原声片段,如果有,则保持原声音量为1.0不变
|
||||
has_original_audio_segments = any(segment['OST'] == 1 for segment in list_script)
|
||||
|
||||
# 应用用户设置和优化建议的组合
|
||||
# 如果用户设置了非默认值,优先使用用户设置
|
||||
final_tts_volume = params.tts_volume if hasattr(params, 'tts_volume') and params.tts_volume != 1.0 else optimized_volumes['tts_volume']
|
||||
final_original_volume = params.original_volume if hasattr(params, 'original_volume') and params.original_volume != 0.7 else optimized_volumes['original_volume']
|
||||
|
||||
# 关键修复:如果有原声片段,保持原声音量为1.0,确保与原视频音量一致
|
||||
if has_original_audio_segments:
|
||||
final_original_volume = 1.0 # 保持原声音量不变
|
||||
logger.info("检测到原声片段,原声音量设置为1.0以保持与原视频一致")
|
||||
else:
|
||||
final_original_volume = params.original_volume if hasattr(params, 'original_volume') and params.original_volume != 0.7 else optimized_volumes['original_volume']
|
||||
|
||||
final_bgm_volume = params.bgm_volume if hasattr(params, 'bgm_volume') and params.bgm_volume != 0.3 else optimized_volumes['bgm_volume']
|
||||
|
||||
logger.info(f"音量配置 - TTS: {final_tts_volume}, 原声: {final_original_volume}, BGM: {final_bgm_volume}")
|
||||
@ -341,11 +361,21 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
|
||||
list_script=new_script_list
|
||||
)
|
||||
logger.info(f"音频文件合并成功->{merged_audio_path}")
|
||||
|
||||
# 合并字幕文件
|
||||
merged_subtitle_path = subtitle_merger.merge_subtitle_files(new_script_list)
|
||||
logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
|
||||
if merged_subtitle_path:
|
||||
logger.info(f"字幕文件合并成功->{merged_subtitle_path}")
|
||||
else:
|
||||
logger.warning("没有有效的字幕内容,将生成无字幕视频")
|
||||
merged_subtitle_path = ""
|
||||
except Exception as e:
|
||||
logger.error(f"合并音频文件失败: {str(e)}")
|
||||
logger.error(f"合并音频/字幕文件失败: {str(e)}")
|
||||
# 确保即使合并失败也有默认值
|
||||
if 'merged_audio_path' not in locals():
|
||||
merged_audio_path = ""
|
||||
if 'merged_subtitle_path' not in locals():
|
||||
merged_subtitle_path = ""
|
||||
else:
|
||||
logger.warning("没有需要合并的音频/字幕")
|
||||
merged_audio_path = ""
|
||||
@ -391,9 +421,19 @@ def start_subclip_unified(task_id: str, params: VideoClipParams):
|
||||
# 获取优化的音量配置
|
||||
optimized_volumes = get_recommended_volumes_for_content('mixed')
|
||||
|
||||
# 检查是否有OST=1的原声片段,如果有,则保持原声音量为1.0不变
|
||||
has_original_audio_segments = any(segment['OST'] == 1 for segment in list_script)
|
||||
|
||||
# 应用用户设置和优化建议的组合
|
||||
final_tts_volume = params.tts_volume if hasattr(params, 'tts_volume') and params.tts_volume != 1.0 else optimized_volumes['tts_volume']
|
||||
final_original_volume = params.original_volume if hasattr(params, 'original_volume') and params.original_volume != 0.7 else optimized_volumes['original_volume']
|
||||
|
||||
# 关键修复:如果有原声片段,保持原声音量为1.0,确保与原视频音量一致
|
||||
if has_original_audio_segments:
|
||||
final_original_volume = 1.0 # 保持原声音量不变
|
||||
logger.info("检测到原声片段,原声音量设置为1.0以保持与原视频一致")
|
||||
else:
|
||||
final_original_volume = params.original_volume if hasattr(params, 'original_volume') and params.original_volume != 0.7 else optimized_volumes['original_volume']
|
||||
|
||||
final_bgm_volume = params.bgm_volume if hasattr(params, 'bgm_volume') and params.bgm_volume != 0.3 else optimized_volumes['bgm_volume']
|
||||
|
||||
logger.info(f"音量配置 - TTS: {final_tts_volume}, 原声: {final_original_volume}, BGM: {final_bgm_volume}")
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
import google.generativeai as genai
|
||||
from app.config import config
|
||||
import os
|
||||
|
||||
os.environ["HTTP_PROXY"] = config.proxy.get("http")
|
||||
os.environ["HTTPS_PROXY"] = config.proxy.get("https")
|
||||
|
||||
genai.configure(api_key="")
|
||||
model = genai.GenerativeModel("gemini-1.5-pro")
|
||||
|
||||
|
||||
for i in range(50):
|
||||
response = model.generate_content("直接回复我文本'当前网络可用'")
|
||||
print(i, response.text)
|
||||
@ -1,122 +0,0 @@
|
||||
"""
|
||||
使用 moviepy 库剪辑指定时间戳视频,支持时分秒毫秒精度
|
||||
"""
|
||||
|
||||
from moviepy.editor import VideoFileClip
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
|
||||
def time_str_to_seconds(time_str: str) -> float:
|
||||
"""
|
||||
将时间字符串转换为秒数
|
||||
参数:
|
||||
time_str: 格式为"HH:MM:SS,mmm"的时间字符串,例如"00:01:23,456"
|
||||
返回:
|
||||
转换后的秒数(float)
|
||||
"""
|
||||
try:
|
||||
# 分离时间和毫秒
|
||||
time_part, ms_part = time_str.split(',')
|
||||
# 转换时分秒
|
||||
time_obj = datetime.strptime(time_part, "%H:%M:%S")
|
||||
# 计算总秒数
|
||||
total_seconds = time_obj.hour * 3600 + time_obj.minute * 60 + time_obj.second
|
||||
# 添加毫秒部分
|
||||
total_seconds += int(ms_part) / 1000
|
||||
return total_seconds
|
||||
except ValueError as e:
|
||||
raise ValueError("时间格式错误,请使用 HH:MM:SS,mmm 格式,例如 00:01:23,456") from e
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
"""
|
||||
将秒数转换为可读的时间格式
|
||||
参数:
|
||||
seconds: 秒数
|
||||
返回:
|
||||
格式化的时间字符串 (HH:MM:SS,mmm)
|
||||
"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
seconds_remain = seconds % 60
|
||||
whole_seconds = int(seconds_remain)
|
||||
milliseconds = int((seconds_remain - whole_seconds) * 1000)
|
||||
|
||||
return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d},{milliseconds:03d}"
|
||||
|
||||
|
||||
def cut_video(video_path: str, start_time: str, end_time: str, output_path: str) -> None:
|
||||
"""
|
||||
剪辑视频
|
||||
参数:
|
||||
video_path: 视频文件路径
|
||||
start_time: 开始时间 (格式: "HH:MM:SS,mmm")
|
||||
end_time: 结束时间 (格式: "HH:MM:SS,mmm")
|
||||
output_path: 输出文件路径
|
||||
"""
|
||||
try:
|
||||
# 确保输出目录存在
|
||||
output_dir = os.path.dirname(output_path)
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
# 如果输出文件已存在,先尝试删除
|
||||
if os.path.exists(output_path):
|
||||
try:
|
||||
os.remove(output_path)
|
||||
except PermissionError:
|
||||
print(f"无法删除已存在的文件:{output_path},请确保文件未被其他程序占用")
|
||||
return
|
||||
|
||||
# 转换时间字符串为秒数
|
||||
start_seconds = time_str_to_seconds(start_time)
|
||||
end_seconds = time_str_to_seconds(end_time)
|
||||
|
||||
# 加载视频文件
|
||||
video = VideoFileClip(video_path)
|
||||
|
||||
# 验证时间范围
|
||||
if start_seconds >= video.duration or end_seconds > video.duration:
|
||||
raise ValueError(f"剪辑时间超出视频长度!视频总长度为: {format_duration(video.duration)}")
|
||||
|
||||
if start_seconds >= end_seconds:
|
||||
raise ValueError("结束时间必须大于开始时间!")
|
||||
|
||||
# 计算剪辑时长
|
||||
clip_duration = end_seconds - start_seconds
|
||||
print(f"原视频总长度: {format_duration(video.duration)}")
|
||||
print(f"剪辑时长: {format_duration(clip_duration)}")
|
||||
print(f"剪辑区间: {start_time} -> {end_time}")
|
||||
|
||||
# 剪辑视频
|
||||
video = video.subclip(start_seconds, end_seconds)
|
||||
|
||||
# 添加错误处理的写入过程
|
||||
try:
|
||||
video.write_videofile(
|
||||
output_path,
|
||||
codec='libx264',
|
||||
audio_codec='aac',
|
||||
temp_audiofile='temp-audio.m4a',
|
||||
remove_temp=True
|
||||
)
|
||||
except IOError as e:
|
||||
print(f"写入视频文件时发生错误:{str(e)}")
|
||||
raise
|
||||
finally:
|
||||
# 确保资源被释放
|
||||
video.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"视频剪辑过程中发生错误:{str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cut_video(
|
||||
video_path="/Users/apple/Desktop/NarratoAI/resource/videos/duanju_yuansp.mp4",
|
||||
start_time="00:00:00,789",
|
||||
end_time="00:02:00,123",
|
||||
output_path="/Users/apple/Desktop/NarratoAI/resource/videos/duanju_yuansp_cut3.mp4"
|
||||
)
|
||||
@ -1,143 +0,0 @@
|
||||
"""
|
||||
使用 moviepy 合并视频、音频、字幕和背景音乐
|
||||
"""
|
||||
|
||||
from moviepy.editor import (
|
||||
VideoFileClip,
|
||||
AudioFileClip,
|
||||
TextClip,
|
||||
CompositeVideoClip,
|
||||
concatenate_videoclips
|
||||
)
|
||||
# from moviepy.config import change_settings
|
||||
import os
|
||||
|
||||
# 设置字体文件路径(用于中文字幕显示)
|
||||
FONT_PATH = "../../resource/fonts/STHeitiMedium.ttc" # 请确保此路径下有对应字体文件
|
||||
# change_settings(
|
||||
# {"IMAGEMAGICK_BINARY": r"C:\Program Files\ImageMagick-7.1.1-Q16\magick.exe"}) # Windows系统需要设置 ImageMagick 路径
|
||||
|
||||
|
||||
class VideoMerger:
|
||||
"""视频合并处理类"""
|
||||
|
||||
def __init__(self, output_path: str = "../../resource/videos/merged_video.mp4"):
|
||||
"""
|
||||
初始化视频合并器
|
||||
参数:
|
||||
output_path: 输出文件路径
|
||||
"""
|
||||
self.output_path = output_path
|
||||
self.video_clips = []
|
||||
self.background_music = None
|
||||
self.subtitles = []
|
||||
|
||||
def add_video(self, video_path: str, start_time: str = None, end_time: str = None) -> None:
|
||||
"""
|
||||
添加视频片段
|
||||
参数:
|
||||
video_path: 视频文件路径
|
||||
start_time: 开始时间 (格式: "MM:SS")
|
||||
end_time: 结束时间 (格式: "MM:SS")
|
||||
"""
|
||||
video = VideoFileClip(video_path)
|
||||
if start_time and end_time:
|
||||
video = video.subclip(self._time_to_seconds(start_time),
|
||||
self._time_to_seconds(end_time))
|
||||
self.video_clips.append(video)
|
||||
|
||||
def add_audio(self, audio_path: str, volume: float = 1.0) -> None:
|
||||
"""
|
||||
添加背景音乐
|
||||
参数:
|
||||
audio_path: 音频文件路径
|
||||
volume: 音量大小 (0.0-1.0)
|
||||
"""
|
||||
self.background_music = AudioFileClip(audio_path).volumex(volume)
|
||||
|
||||
def add_subtitle(self, text: str, start_time: str, end_time: str,
|
||||
position: tuple = ('center', 'bottom'), fontsize: int = 24) -> None:
|
||||
"""
|
||||
添加字幕
|
||||
参数:
|
||||
text: 字幕文本
|
||||
start_time: 开始时间 (格式: "MM:SS")
|
||||
end_time: 结束时间 (格式: "MM:SS")
|
||||
position: 字幕位置
|
||||
fontsize: 字体大小
|
||||
"""
|
||||
subtitle = TextClip(
|
||||
text,
|
||||
font=FONT_PATH,
|
||||
fontsize=fontsize,
|
||||
color='white',
|
||||
stroke_color='black',
|
||||
stroke_width=2
|
||||
)
|
||||
|
||||
subtitle = subtitle.set_position(position).set_duration(
|
||||
self._time_to_seconds(end_time) - self._time_to_seconds(start_time)
|
||||
).set_start(self._time_to_seconds(start_time))
|
||||
|
||||
self.subtitles.append(subtitle)
|
||||
|
||||
def merge(self) -> None:
|
||||
"""合并所有媒体元素并导出视频"""
|
||||
if not self.video_clips:
|
||||
raise ValueError("至少需要添加一个视频片段")
|
||||
|
||||
# 合并视频片段
|
||||
final_video = concatenate_videoclips(self.video_clips)
|
||||
|
||||
# 如果有背景音乐,设置其持续时间与视频相同
|
||||
if self.background_music:
|
||||
self.background_music = self.background_music.set_duration(final_video.duration)
|
||||
final_video = final_video.set_audio(self.background_music)
|
||||
|
||||
# 添加字幕
|
||||
if self.subtitles:
|
||||
final_video = CompositeVideoClip([final_video] + self.subtitles)
|
||||
|
||||
# 导出最终视频
|
||||
final_video.write_videofile(
|
||||
self.output_path,
|
||||
fps=24,
|
||||
codec='libx264',
|
||||
audio_codec='aac'
|
||||
)
|
||||
|
||||
# 释放资源
|
||||
final_video.close()
|
||||
for clip in self.video_clips:
|
||||
clip.close()
|
||||
if self.background_music:
|
||||
self.background_music.close()
|
||||
|
||||
@staticmethod
|
||||
def _time_to_seconds(time_str: str) -> float:
|
||||
"""将时间字符串转换为秒数"""
|
||||
minutes, seconds = map(int, time_str.split(':'))
|
||||
return minutes * 60 + seconds
|
||||
|
||||
|
||||
def test_merge_video():
|
||||
"""测试视频合并功能"""
|
||||
merger = VideoMerger()
|
||||
|
||||
# 添加两个视频片段
|
||||
merger.add_video("../../resource/videos/cut_video.mp4", "00:00", "01:00")
|
||||
merger.add_video("../../resource/videos/demo.mp4", "00:00", "00:30")
|
||||
|
||||
# 添加背景音乐
|
||||
merger.add_audio("../../resource/songs/output000.mp3", volume=0.3)
|
||||
|
||||
# 添加字幕
|
||||
merger.add_subtitle("第一个精彩片段", "00:00", "00:05")
|
||||
merger.add_subtitle("第二个精彩片段", "01:00", "01:05")
|
||||
|
||||
# 合并并导出
|
||||
merger.merge()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_merge_video()
|
||||
@ -1,142 +0,0 @@
|
||||
"""
|
||||
使用 moviepy 优化视频处理速度的示例
|
||||
包含:视频加速、多核处理、预设参数优化等
|
||||
"""
|
||||
|
||||
from moviepy.editor import VideoFileClip
|
||||
from moviepy.video.fx.speedx import speedx
|
||||
import multiprocessing as mp
|
||||
import time
|
||||
|
||||
|
||||
class VideoSpeedProcessor:
|
||||
"""视频速度处理器"""
|
||||
|
||||
def __init__(self, input_path: str, output_path: str):
|
||||
self.input_path = input_path
|
||||
self.output_path = output_path
|
||||
# 获取CPU核心数
|
||||
self.cpu_cores = mp.cpu_count()
|
||||
|
||||
def process_with_optimization(self, speed_factor: float = 1.0) -> None:
|
||||
"""
|
||||
使用优化参数处理视频
|
||||
参数:
|
||||
speed_factor: 速度倍数 (1.0 为原速, 2.0 为双倍速)
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# 加载视频时使用优化参数
|
||||
video = VideoFileClip(
|
||||
self.input_path,
|
||||
audio=True, # 如果不需要音频可以设为False
|
||||
target_resolution=(720, None), # 可以降低分辨率加快处理
|
||||
resize_algorithm='fast_bilinear' # 使用快速的重置算法
|
||||
)
|
||||
|
||||
# 应用速度变化
|
||||
if speed_factor != 1.0:
|
||||
video = speedx(video, factor=speed_factor)
|
||||
|
||||
# 使用优化参数导出视频
|
||||
video.write_videofile(
|
||||
self.output_path,
|
||||
codec='libx264', # 使用h264编码
|
||||
audio_codec='aac', # 音频编码
|
||||
temp_audiofile='temp-audio.m4a', # 临时音频文件
|
||||
remove_temp=True, # 处理完成后删除临时文件
|
||||
write_logfile=False, # 关闭日志文件
|
||||
threads=self.cpu_cores, # 使用多核处理
|
||||
preset='ultrafast', # 使用最快的编码预设
|
||||
ffmpeg_params=[
|
||||
'-brand', 'mp42',
|
||||
'-crf', '23', # 压缩率,范围0-51,数值越大压缩率越高
|
||||
]
|
||||
)
|
||||
|
||||
# 释放资源
|
||||
video.close()
|
||||
|
||||
end_time = time.time()
|
||||
print(f"处理完成!用时: {end_time - start_time:.2f} 秒")
|
||||
|
||||
def batch_process_segments(self, segment_times: list, speed_factor: float = 1.0) -> None:
|
||||
"""
|
||||
批量处理视频片段(并行处理)
|
||||
参数:
|
||||
segment_times: 列表,包含多个(start, end)时间元组
|
||||
speed_factor: 速度倍数
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# 创建进程池
|
||||
with mp.Pool(processes=self.cpu_cores) as pool:
|
||||
# 准备参数
|
||||
args = [(self.input_path, start, end, speed_factor, i)
|
||||
for i, (start, end) in enumerate(segment_times)]
|
||||
|
||||
# 并行处理片段
|
||||
pool.starmap(self._process_segment, args)
|
||||
|
||||
end_time = time.time()
|
||||
print(f"批量处理完成!总用时: {end_time - start_time:.2f} 秒")
|
||||
|
||||
@staticmethod
|
||||
def _process_segment(video_path: str, start: str, end: str,
|
||||
speed_factor: float, index: int) -> None:
|
||||
"""处理单个视频片段"""
|
||||
# 转换时间格式
|
||||
start_sec = VideoSpeedProcessor._time_to_seconds(start)
|
||||
end_sec = VideoSpeedProcessor._time_to_seconds(end)
|
||||
|
||||
# 加载并处理视频片段
|
||||
video = VideoFileClip(
|
||||
video_path,
|
||||
audio=True,
|
||||
target_resolution=(720, None)
|
||||
).subclip(start_sec, end_sec)
|
||||
|
||||
# 应用速度变化
|
||||
if speed_factor != 1.0:
|
||||
video = speedx(video, factor=speed_factor)
|
||||
|
||||
# 保存处理后的片段
|
||||
output_path = f"../../resource/videos/segment_{index}.mp4"
|
||||
video.write_videofile(
|
||||
output_path,
|
||||
codec='libx264',
|
||||
audio_codec='aac',
|
||||
preset='ultrafast',
|
||||
threads=2 # 每个进程使用的线程数
|
||||
)
|
||||
|
||||
video.close()
|
||||
|
||||
@staticmethod
|
||||
def _time_to_seconds(time_str: str) -> float:
|
||||
"""将时间字符串(MM:SS)转换为秒数"""
|
||||
minutes, seconds = map(int, time_str.split(':'))
|
||||
return minutes * 60 + seconds
|
||||
|
||||
|
||||
def test_video_speed():
|
||||
"""测试视频加速处理"""
|
||||
processor = VideoSpeedProcessor(
|
||||
"../../resource/videos/best.mp4",
|
||||
"../../resource/videos/speed_up.mp4"
|
||||
)
|
||||
|
||||
# 测试1:简单加速
|
||||
processor.process_with_optimization(speed_factor=1.5) # 1.5倍速
|
||||
|
||||
# 测试2:并行处理多个片段
|
||||
segments = [
|
||||
("00:00", "01:00"),
|
||||
("01:00", "02:00"),
|
||||
("02:00", "03:00")
|
||||
]
|
||||
processor.batch_process_segments(segments, speed_factor=2.0) # 2倍速
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_video_speed()
|
||||
@ -1,105 +0,0 @@
|
||||
import os
|
||||
import traceback
|
||||
import json
|
||||
from openai import OpenAI
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from app.utils import utils
|
||||
from app.services.subtitle import extract_audio_and_create_subtitle
|
||||
|
||||
|
||||
class Step(BaseModel):
|
||||
timestamp: str
|
||||
picture: str
|
||||
narration: str
|
||||
OST: int
|
||||
new_timestamp: str
|
||||
|
||||
class MathReasoning(BaseModel):
|
||||
result: List[Step]
|
||||
|
||||
|
||||
def chat_with_qwen(prompt: str, system_message: str, subtitle_path: str) -> str:
|
||||
"""
|
||||
与通义千问AI模型进行对话
|
||||
|
||||
Args:
|
||||
prompt (str): 用户输入的问题或提示
|
||||
system_message (str): 系统提示信息,用于设定AI助手的行为。默认为"You are a helpful assistant."
|
||||
subtitle_path (str): 字幕文件路径
|
||||
Returns:
|
||||
str: AI助手的回复内容
|
||||
|
||||
Raises:
|
||||
Exception: 当API调用失败时抛出异常
|
||||
"""
|
||||
try:
|
||||
client = OpenAI(
|
||||
api_key="sk-a1acd853d88d41d3ae92777d7bfa2612",
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
)
|
||||
|
||||
# 读取字幕文件
|
||||
with open(subtitle_path, "r", encoding="utf-8") as file:
|
||||
subtitle_content = file.read()
|
||||
|
||||
completion = client.chat.completions.create(
|
||||
model="qwen-turbo-2024-11-01",
|
||||
messages=[
|
||||
{'role': 'system', 'content': system_message},
|
||||
{'role': 'user', 'content': prompt + subtitle_content}
|
||||
]
|
||||
)
|
||||
return completion.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"调用千问API时发生错误:{str(e)}"
|
||||
print(error_message)
|
||||
print("请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code")
|
||||
raise Exception(error_message)
|
||||
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
video_path = utils.video_dir("duanju_yuansp.mp4")
|
||||
# # 判断视频是否存在
|
||||
# if not os.path.exists(video_path):
|
||||
# print(f"视频文件不存在:{video_path}")
|
||||
# exit(1)
|
||||
# 提取字幕
|
||||
subtitle_path = os.path.join(utils.video_dir(""), f"duanju_yuan.srt")
|
||||
extract_audio_and_create_subtitle(video_file=video_path, subtitle_file=subtitle_path)
|
||||
# 分析字幕
|
||||
system_message = """
|
||||
你是一个视频srt字幕分析剪辑器, 输入视频的srt字幕, 分析其中的精彩且尽可能连续的片段并裁剪出来, 注意确保文字与时间戳的正确匹配。
|
||||
输出需严格按照如下 json 格式:
|
||||
[
|
||||
{
|
||||
"timestamp": "00:00:50,020-00,01:44,000",
|
||||
"picture": "画面1",
|
||||
"narration": "播放原声",
|
||||
"OST": 0,
|
||||
"new_timestamp": "00:00:00,000-00:00:54,020"
|
||||
},
|
||||
{
|
||||
"timestamp": "01:49-02:30",
|
||||
"picture": "画面2",
|
||||
"narration": "播放原声",
|
||||
"OST": 2,
|
||||
"new_timestamp": "00:54-01:35"
|
||||
},
|
||||
]
|
||||
"""
|
||||
prompt = "字幕如下:\n"
|
||||
response = chat_with_qwen(prompt, system_message, subtitle_path)
|
||||
print(response)
|
||||
# 保存json,注意json中是时间戳需要转换为 分:秒(现在的时间是 "timestamp": "00:00:00,020-00:00:01,660", 需要转换为 "timestamp": "00:00-01:66")
|
||||
# response = json.loads(response)
|
||||
# for item in response:
|
||||
# item["timestamp"] = item["timestamp"].replace(":", "-")
|
||||
# with open(os.path.join(utils.video_dir(""), "duanju_yuan.json"), "w", encoding="utf-8") as file:
|
||||
# json.dump(response, file, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
print(traceback.format_exc())
|
||||
@ -1,5 +1,5 @@
|
||||
[app]
|
||||
project_version="0.7.0"
|
||||
project_version="0.7.1"
|
||||
|
||||
# 模型验证模式配置
|
||||
# true: 严格模式,只允许使用预定义支持列表中的模型(默认)
|
||||
|
||||
@ -1,38 +1,29 @@
|
||||
x-common: &common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: linyq1/narratoai:latest
|
||||
volumes:
|
||||
- ./:/NarratoAI
|
||||
environment:
|
||||
- VPN_PROXY_URL=http://host.docker.internal:7890
|
||||
- PYTHONUNBUFFERED=1
|
||||
- PYTHONMALLOC=malloc
|
||||
- OPENCV_OPENCL_RUNTIME=disabled
|
||||
- OPENCV_CPU_DISABLE=0
|
||||
restart: always
|
||||
mem_limit: 4g
|
||||
mem_reservation: 2g
|
||||
memswap_limit: 6g
|
||||
cpus: 2.0
|
||||
cpu_shares: 1024
|
||||
|
||||
services:
|
||||
webui:
|
||||
<<: *common
|
||||
container_name: webui
|
||||
narratoai-webui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: narratoai:latest
|
||||
container_name: narratoai-webui
|
||||
|
||||
ports:
|
||||
- "8501:8501"
|
||||
command: ["webui"]
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "3"
|
||||
tmpfs:
|
||||
- /tmp:size=1G
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
|
||||
volumes:
|
||||
- ./storage:/NarratoAI/storage
|
||||
- ./config.toml:/NarratoAI/config.toml
|
||||
- ./resource:/NarratoAI/resource:ro
|
||||
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
# 健康检查
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8501/_stcore/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
184
docker-deploy.sh
Executable file
184
docker-deploy.sh
Executable file
@ -0,0 +1,184 @@
|
||||
#!/bin/bash
|
||||
|
||||
# NarratoAI Docker 一键部署脚本
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 日志函数
|
||||
log_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 显示帮助信息
|
||||
show_help() {
|
||||
cat << EOF
|
||||
NarratoAI Docker 一键部署脚本
|
||||
|
||||
使用方法:
|
||||
$0 [选项]
|
||||
|
||||
选项:
|
||||
-h, --help 显示此帮助信息
|
||||
-b, --build 强制重新构建镜像
|
||||
--no-cache 构建时不使用缓存
|
||||
|
||||
示例:
|
||||
$0 # 标准部署
|
||||
$0 -b # 重新构建并部署
|
||||
$0 --no-cache # 无缓存构建
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# 检查系统要求
|
||||
check_requirements() {
|
||||
log_info "检查系统要求..."
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker 未安装,请先安装 Docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
log_error "Docker Compose 未安装,请先安装 Docker Compose"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info &> /dev/null; then
|
||||
log_error "Docker 服务未运行,请启动 Docker"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查配置文件
|
||||
check_config() {
|
||||
if [ ! -f "config.toml" ]; then
|
||||
if [ -f "config.example.toml" ]; then
|
||||
log_warning "config.toml 不存在,复制示例配置文件"
|
||||
cp config.example.toml config.toml
|
||||
log_info "请编辑 config.toml 文件配置您的 API 密钥"
|
||||
else
|
||||
log_error "未找到配置文件模板"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 构建镜像
|
||||
build_image() {
|
||||
log_info "构建 Docker 镜像..."
|
||||
|
||||
local build_args=""
|
||||
if [ "$NO_CACHE" = "true" ]; then
|
||||
build_args="--no-cache"
|
||||
fi
|
||||
|
||||
docker-compose build $build_args
|
||||
}
|
||||
|
||||
# 启动服务
|
||||
start_services() {
|
||||
log_info "启动 NarratoAI 服务..."
|
||||
|
||||
docker-compose down 2>/dev/null || true
|
||||
docker-compose up -d
|
||||
}
|
||||
|
||||
# 等待服务就绪
|
||||
wait_for_service() {
|
||||
log_info "等待服务就绪..."
|
||||
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if curl -f http://localhost:8501/_stcore/health &>/dev/null; then
|
||||
log_info "服务已就绪"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
((attempt++))
|
||||
done
|
||||
|
||||
log_warning "服务启动超时,请检查日志"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 显示部署信息
|
||||
show_deployment_info() {
|
||||
echo
|
||||
log_info "NarratoAI 部署完成!"
|
||||
echo "访问地址: http://localhost:8501"
|
||||
echo
|
||||
echo "常用命令:"
|
||||
echo " 查看日志: docker-compose logs -f"
|
||||
echo " 停止服务: docker-compose down"
|
||||
echo " 重启服务: docker-compose restart"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
FORCE_BUILD=false
|
||||
NO_CACHE=false
|
||||
|
||||
# 解析命令行参数
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-b|--build)
|
||||
FORCE_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--no-cache)
|
||||
NO_CACHE=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
log_error "未知选项: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 执行部署流程
|
||||
log_info "开始 NarratoAI Docker 部署..."
|
||||
|
||||
check_requirements
|
||||
check_config
|
||||
|
||||
if [ "$FORCE_BUILD" = "true" ] || ! docker images | grep -q "narratoai"; then
|
||||
build_image
|
||||
fi
|
||||
|
||||
start_services
|
||||
|
||||
if wait_for_service; then
|
||||
show_deployment_info
|
||||
else
|
||||
log_error "部署失败,请检查日志"
|
||||
docker-compose logs --tail=20
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
@ -1,8 +1,87 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ "$1" = "webui" ]; then
|
||||
exec streamlit run webui.py --browser.serverAddress=127.0.0.1 --server.enableCORS=True --browser.gatherUsageStats=False
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
# 函数:打印日志
|
||||
log() {
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
# 函数:检查必要的文件和目录
|
||||
check_requirements() {
|
||||
log "检查应用环境..."
|
||||
|
||||
# 检查配置文件
|
||||
if [ ! -f "config.toml" ]; then
|
||||
if [ -f "config.example.toml" ]; then
|
||||
log "复制示例配置文件..."
|
||||
cp config.example.toml config.toml
|
||||
else
|
||||
log "警告: 未找到配置文件"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查必要的目录
|
||||
for dir in "storage/temp" "storage/tasks" "storage/json" "storage/narration_scripts" "storage/drama_analysis"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
log "创建目录: $dir"
|
||||
mkdir -p "$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
log "环境检查完成"
|
||||
}
|
||||
|
||||
# 函数:启动 WebUI
|
||||
start_webui() {
|
||||
log "启动 NarratoAI WebUI..."
|
||||
|
||||
# 检查端口是否可用
|
||||
if command -v netstat >/dev/null 2>&1; then
|
||||
if netstat -tuln | grep -q ":8501 "; then
|
||||
log "警告: 端口 8501 已被占用"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 启动 Streamlit 应用
|
||||
exec streamlit run webui.py \
|
||||
--server.address=0.0.0.0 \
|
||||
--server.port=8501 \
|
||||
--server.enableCORS=true \
|
||||
--server.maxUploadSize=2048 \
|
||||
--server.enableXsrfProtection=false \
|
||||
--browser.gatherUsageStats=false \
|
||||
--browser.serverAddress=0.0.0.0 \
|
||||
--logger.level=info
|
||||
}
|
||||
|
||||
# 主逻辑
|
||||
log "NarratoAI Docker 容器启动中..."
|
||||
|
||||
# 检查环境
|
||||
check_requirements
|
||||
|
||||
# 根据参数执行不同的命令
|
||||
case "$1" in
|
||||
"webui"|"")
|
||||
start_webui
|
||||
;;
|
||||
"bash"|"sh")
|
||||
log "启动交互式 shell..."
|
||||
exec /bin/bash
|
||||
;;
|
||||
"health")
|
||||
# 健康检查命令
|
||||
log "执行健康检查..."
|
||||
if curl -f http://localhost:8501/_stcore/health >/dev/null 2>&1; then
|
||||
log "健康检查通过"
|
||||
exit 0
|
||||
else
|
||||
log "健康检查失败"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log "执行自定义命令: $*"
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
@ -1 +1 @@
|
||||
0.7.0
|
||||
0.7.1
|
||||
@ -1,47 +1,35 @@
|
||||
# 必须项
|
||||
requests~=2.32.0
|
||||
# 核心依赖
|
||||
requests>=2.32.0
|
||||
moviepy==2.1.1
|
||||
edge-tts==6.1.19
|
||||
streamlit~=1.45.0
|
||||
streamlit>=1.45.0
|
||||
watchdog==6.0.0
|
||||
loguru~=0.7.3
|
||||
tomli~=2.2.1
|
||||
loguru>=0.7.3
|
||||
tomli>=2.2.1
|
||||
tomli-w>=1.0.0
|
||||
pydub==0.25.1
|
||||
pysrt==1.1.2
|
||||
|
||||
openai~=1.77.0
|
||||
# AI 服务依赖
|
||||
openai>=1.77.0
|
||||
google-generativeai>=0.8.5
|
||||
azure-cognitiveservices-speech~=1.37.0
|
||||
azure-cognitiveservices-speech>=1.37.0
|
||||
|
||||
# 待优化项
|
||||
# opencv-python==4.11.0.86
|
||||
# scikit-learn==1.6.1
|
||||
# 图像处理依赖
|
||||
Pillow>=10.3.0
|
||||
|
||||
# fastapi~=0.115.4
|
||||
# uvicorn~=0.27.1
|
||||
# pydantic~=2.11.4
|
||||
# 进度条和重试机制
|
||||
tqdm>=4.66.6
|
||||
tenacity>=9.0.0
|
||||
|
||||
# faster-whisper~=1.0.1
|
||||
# tomli~=2.0.1
|
||||
# aiohttp~=3.10.10
|
||||
# httpx==0.27.2
|
||||
# urllib3~=2.2.1
|
||||
# 可选依赖(根据功能需要)
|
||||
# 如果需要本地语音识别,取消注释下面的行
|
||||
# faster-whisper>=1.0.1
|
||||
|
||||
# python-multipart~=0.0.9
|
||||
# redis==5.0.3
|
||||
# opencv-python~=4.10.0.84
|
||||
# git-changelog~=2.5.2
|
||||
# watchdog==5.0.2
|
||||
# pydub==0.25.1
|
||||
# psutil>=5.9.0
|
||||
# scikit-learn~=1.5.2
|
||||
# pillow==10.3.0
|
||||
# python-dotenv~=1.0.1
|
||||
# 如果需要 OpenCV 图像处理,取消注释下面的行
|
||||
# opencv-python>=4.11.0.86
|
||||
|
||||
# tqdm>=4.66.6
|
||||
# tenacity>=9.0.0
|
||||
# tiktoken==0.8.0
|
||||
# pysrt==1.1.2
|
||||
# transformers==4.50.0
|
||||
|
||||
# yt-dlp==2025.4.30
|
||||
# 如果需要 CUDA 支持,取消注释下面的行
|
||||
# torch>=2.0.0
|
||||
# torchvision>=0.15.0
|
||||
# torchaudio>=2.0.0
|
||||
@ -157,15 +157,15 @@
|
||||
"API rate limit exceeded. Please wait about an hour and try again.": "API 调用次数已达到限制,请等待约一小时后再试。",
|
||||
"Resources exhausted. Please try again later.": "资源已耗尽,请稍后再试。",
|
||||
"Transcription Failed": "转录失败",
|
||||
"Short Generate": "短剧混剪 (高燃剪辑)",
|
||||
"Short Generate": "短剧混剪 (实验)",
|
||||
"Generate Short Video Script": "AI生成短剧混剪脚本",
|
||||
"Adjust the volume of the original audio": "调整原始音频的音量",
|
||||
"Original Volume": "视频音量",
|
||||
"Auto Generate": "纪录片解说 (画面解说)",
|
||||
"Auto Generate": "逐帧解说",
|
||||
"Frame Interval (seconds)": "帧间隔 (秒)",
|
||||
"Frame Interval (seconds) (More keyframes consume more tokens)": "帧间隔 (秒) (更多关键帧消耗更多令牌)",
|
||||
"Batch Size": "批处理大小",
|
||||
"Batch Size (More keyframes consume more tokens)": "批处理大小, 每批处理越少消耗 token 越多",
|
||||
"Short Drama Summary": "短剧解说(仅支持 gemini-2.0-flash)"
|
||||
"Short Drama Summary": "短剧解说"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user