Merge pull request #170 from linyqh/dev071

Dev071
This commit is contained in:
viccy 2025-08-18 14:52:15 +08:00 committed by GitHub
commit 539dc7f27a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 756 additions and 1864 deletions

View File

@ -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

View File

@ -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
View 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

View File

@ -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软件编码")

View File

@ -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}",
)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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)

View File

@ -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:]}",
# )

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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]"

View File

@ -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__':

View File

@ -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}")

View File

@ -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)

View File

@ -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"
)

View File

@ -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()

View File

@ -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()

View File

@ -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())

View File

@ -1,5 +1,5 @@
[app]
project_version="0.7.0"
project_version="0.7.1"
# 模型验证模式配置
# true: 严格模式,只允许使用预定义支持列表中的模型(默认)

View File

@ -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
View 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 "$@"

View File

@ -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

View File

@ -1 +1 @@
0.7.0
0.7.1

View File

@ -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

View File

@ -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": "短剧解说"
}
}