Merge pull request 'fix(ai-avatar, P0): FFmpeg subprocess list修复32512 + Celery异常raise + 投递失败写DB + 封面drawtext标题 + audio/x-wav白名单' (#1859) from fix/ai-avatar-ffmpeg-cover-wav into develop
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m4s
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m14s
CI/CD Pipeline / Validate - Style (push) Successful in 2m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 119h29m24s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 119h30m32s
CI/CD Pipeline / PR Build API Image (push) Failing after 119h30m34s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 119h29m5s
CI/CD Pipeline / PR Build Web Image (push) Failing after 119h30m33s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 119h29m8s
CI/CD Pipeline / Frontend Lint (push) Failing after 119h30m35s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 119h30m39s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 20s
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m4s
CI/CD Pipeline / Integration Tests (push) Successful in 1m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m14s
CI/CD Pipeline / Validate - Style (push) Successful in 2m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 119h29m24s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 119h30m32s
CI/CD Pipeline / PR Build API Image (push) Failing after 119h30m34s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 119h29m5s
CI/CD Pipeline / PR Build Web Image (push) Failing after 119h30m33s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 119h29m8s
CI/CD Pipeline / Frontend Lint (push) Failing after 119h30m35s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 119h30m39s
This commit was merged in pull request #1859.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
@@ -77,10 +78,16 @@ def create_render_job(
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
|
||||
except Exception as exc:
|
||||
logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
|
||||
return job
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
@@ -172,10 +179,16 @@ def retry_render_job(
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
|
||||
except Exception as exc:
|
||||
logger.exception("Celery 任务投递失败(重试): job_id=%s err=%s", job.id, exc)
|
||||
job.status = "failed"
|
||||
job.error_message = f"任务提交失败:{exc}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
svc.db.commit()
|
||||
svc.db.refresh(job)
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
|
||||
return job
|
||||
return AiAvatarRenderJobResponse.model_validate(job)
|
||||
|
||||
|
||||
|
||||
@@ -199,7 +212,11 @@ def generate_avatar_smart_cover(
|
||||
raise HTTPException(status_code=400, detail="video_url 必须是合法的 HTTP/HTTPS URL")
|
||||
|
||||
try:
|
||||
cover_url = generate_smart_cover(video_url, max_frames=body.max_frames)
|
||||
cover_url = generate_smart_cover(
|
||||
video_url,
|
||||
max_frames=body.max_frames,
|
||||
title_config=getattr(body, "title_config", None),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"智能封面生成异常: user=%s video_url=%s err=%s",
|
||||
|
||||
@@ -110,10 +110,13 @@ class AiAvatarRenderProgressResponse(BaseModel):
|
||||
|
||||
|
||||
class SmartCoverRequest(BaseModel):
|
||||
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧."""
|
||||
"""智能封面请求 — MediaKit 抽帧 + 质量评分选最佳帧 + 可选标题 drawtext 叠加."""
|
||||
|
||||
video_url: str = Field(..., description="数字人视频 URL(对口型/渲染成片)")
|
||||
max_frames: int = Field(5, ge=1, le=10, description="抽帧数量(默认 5)")
|
||||
title_config: Optional[dict[str, Any]] = Field(
|
||||
None, description="标题配置;传入时在封面上用 drawtext 叠加标题(竖屏 720x1280)"
|
||||
)
|
||||
|
||||
|
||||
class SmartCoverResponse(BaseModel):
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -156,13 +158,79 @@ def select_best_cover_frame(video_url: str, *, max_frames: int = 5) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-avatar/covers") -> str:
|
||||
def apply_title_to_cover(local_frame: str, *, title_config: dict | None) -> str:
|
||||
"""用 ffmpeg drawtext 在封面图上叠加标题,返回叠加后图片的本地路径.
|
||||
|
||||
ffmpeg 失败时回退返回原始 local_frame。竖屏封面按 720x1280 计算位置。
|
||||
"""
|
||||
if not title_config or not isinstance(title_config, dict):
|
||||
return local_frame
|
||||
text = (title_config.get("text") or title_config.get("content") or "").strip()
|
||||
if not text:
|
||||
return local_frame
|
||||
enabled = title_config.get("enabled", True)
|
||||
if not enabled:
|
||||
return local_frame
|
||||
|
||||
try:
|
||||
from packages.domain.video_filter_builder import build_title_drawtext_filter
|
||||
|
||||
drawtext_filter = build_title_drawtext_filter(
|
||||
title_config,
|
||||
output_width=720,
|
||||
output_height=1280,
|
||||
)
|
||||
if not drawtext_filter:
|
||||
return local_frame
|
||||
|
||||
base, ext = os.path.splitext(local_frame)
|
||||
titled_path = f"{base}_titled{ext or '.jpg'}"
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
local_frame,
|
||||
"-vf",
|
||||
drawtext_filter,
|
||||
"-y",
|
||||
titled_path,
|
||||
]
|
||||
logger.info("[数字人封面] 叠加标题: text=%s", text[:30])
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"[数字人封面] drawtext 失败,回退无标题: exit=%s stderr=%s",
|
||||
result.returncode,
|
||||
(result.stderr or "")[-300:],
|
||||
)
|
||||
return local_frame
|
||||
if not os.path.exists(titled_path) or os.path.getsize(titled_path) == 0:
|
||||
logger.warning("[数字人封面] drawtext 输出为空,回退无标题")
|
||||
return local_frame
|
||||
return titled_path
|
||||
except Exception as exc:
|
||||
logger.warning("[数字人封面] 标题叠加异常,回退无标题: %s", exc, exc_info=True)
|
||||
return local_frame
|
||||
|
||||
|
||||
def persist_cover_to_oss(
|
||||
frame_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
prefix: str = "ai-avatar/covers",
|
||||
title_config: dict | None = None,
|
||||
) -> str:
|
||||
"""下载帧图并转存到 OSS,返回公网封面 URL.
|
||||
|
||||
Args:
|
||||
frame_url: MediaKit 返回的临时帧图 URL
|
||||
job_id: 关联任务 ID(用于 OSS key 命名)
|
||||
prefix: OSS key 前缀
|
||||
title_config: 可选标题配置;传入时用 drawtext 叠加标题(竖屏 720x1280)
|
||||
|
||||
Returns:
|
||||
OSS 公网 URL;失败回退原始 frame_url
|
||||
@@ -170,6 +238,7 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-
|
||||
if not frame_url:
|
||||
return ""
|
||||
tmp_path: Optional[str] = None
|
||||
titled_path: Optional[str] = None
|
||||
try:
|
||||
import httpx
|
||||
|
||||
@@ -189,12 +258,21 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-
|
||||
storage = get_shared_storage_service()
|
||||
token = job_id or uuid.uuid4().hex[:12]
|
||||
cover_key = f"{prefix}/{token}/cover_{uuid.uuid4().hex[:8]}.jpg"
|
||||
|
||||
upload_path = apply_title_to_cover(tmp_path, title_config=title_config)
|
||||
if upload_path != tmp_path:
|
||||
titled_path = upload_path
|
||||
|
||||
public_url = storage.upload_file(
|
||||
file_or_path=tmp_path,
|
||||
file_or_path=upload_path,
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
logger.info("[数字人封面] 封面已转存 OSS: key=%s", cover_key)
|
||||
logger.info(
|
||||
"[数字人封面] 封面已转存 OSS: key=%s titled=%s",
|
||||
cover_key,
|
||||
bool(titled_path),
|
||||
)
|
||||
# 私有桶:返回预签名 URL(前端才能加载)
|
||||
if public_url:
|
||||
signed = storage.get_download_url(cover_key, expires_seconds=86400)
|
||||
@@ -204,19 +282,32 @@ def persist_cover_to_oss(frame_url: str, *, job_id: str = "", prefix: str = "ai-
|
||||
logger.warning("[数字人封面] 封面转存 OSS 失败,返回原始 URL", exc_info=True)
|
||||
return frame_url
|
||||
finally:
|
||||
if tmp_path:
|
||||
try:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
for p in (tmp_path, titled_path):
|
||||
if p:
|
||||
try:
|
||||
Path(p).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def generate_smart_cover(video_url: str, *, job_id: str = "", max_frames: int = 5) -> str:
|
||||
"""一站式:MediaKit 智能抽帧选最佳 → 转存 OSS,返回封面公网 URL.
|
||||
def generate_smart_cover(
|
||||
video_url: str,
|
||||
*,
|
||||
job_id: str = "",
|
||||
max_frames: int = 5,
|
||||
title_config: dict | None = None,
|
||||
) -> str:
|
||||
"""一站式:MediaKit 智能抽帧选最佳 → (可选)drawtext 叠加标题 → 转存 OSS.
|
||||
|
||||
供独立封面接口与渲染管线复用。失败返回空字符串。
|
||||
|
||||
Args:
|
||||
video_url: 可公网访问的视频 URL
|
||||
job_id: 关联任务 ID
|
||||
max_frames: 抽帧数量
|
||||
title_config: 可选标题配置;传入时在封面上叠加 drawtext 标题(竖屏 720x1280)
|
||||
"""
|
||||
best_frame = select_best_cover_frame(video_url, max_frames=max_frames)
|
||||
if not best_frame:
|
||||
return ""
|
||||
return persist_cover_to_oss(best_frame, job_id=job_id)
|
||||
return persist_cover_to_oss(best_frame, job_id=job_id, title_config=title_config)
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -24,7 +25,6 @@ from packages.adapters.sqlalchemy_impl.models import (
|
||||
ScriptModel,
|
||||
)
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_cover_extract_command,
|
||||
build_title_drawtext_filter,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
@@ -257,7 +257,7 @@ class AiAvatarRenderService:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_video_path = os.path.join(tmpdir, "output.mp4")
|
||||
|
||||
cmd = self._build_ffmpeg_command(
|
||||
cmd_list = self._build_ffmpeg_command(
|
||||
input_video=input_video_path,
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
filter_complex=filter_complex,
|
||||
@@ -265,9 +265,25 @@ class AiAvatarRenderService:
|
||||
output_path=output_video_path,
|
||||
)
|
||||
|
||||
exit_code = os.system(cmd)
|
||||
if exit_code != 0:
|
||||
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
|
||||
try:
|
||||
render_result = subprocess.run(
|
||||
cmd_list,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AiAvatarRenderError(
|
||||
"FFmpeg 渲染超时(600s)",
|
||||
code="FFmpegTimeout",
|
||||
) from exc
|
||||
|
||||
if render_result.returncode != 0:
|
||||
stderr_tail = (render_result.stderr or "").strip()[-800:]
|
||||
raise AiAvatarRenderError(
|
||||
f"FFmpeg 渲染失败,退出码: {render_result.returncode}, stderr: {stderr_tail}",
|
||||
code="FFmpegFailed",
|
||||
)
|
||||
|
||||
job.progress = 80
|
||||
self.db.commit()
|
||||
@@ -276,11 +292,27 @@ class AiAvatarRenderService:
|
||||
cover_path = ""
|
||||
if job.cover_config:
|
||||
cover_path = os.path.join(tmpdir, "cover.jpg")
|
||||
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
|
||||
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
|
||||
cover_exit = os.system(cover_cmd)
|
||||
if cover_exit != 0:
|
||||
logger.warning("封面提取失败,跳过: %s", cover_cmd)
|
||||
cover_cmd = self._build_cover_extract_cmd(
|
||||
cover_config=job.cover_config,
|
||||
input_video=output_video_path,
|
||||
output_path=cover_path,
|
||||
)
|
||||
try:
|
||||
cover_result = subprocess.run(
|
||||
cover_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if cover_result.returncode != 0:
|
||||
logger.warning(
|
||||
"封面提取失败(非致命),跳过: exit=%s stderr=%s",
|
||||
cover_result.returncode,
|
||||
(cover_result.stderr or "")[-300:],
|
||||
)
|
||||
cover_path = ""
|
||||
except Exception as cover_err:
|
||||
logger.warning("封面提取异常(非致命),跳过: %s", cover_err)
|
||||
cover_path = ""
|
||||
|
||||
job.progress = 90
|
||||
@@ -290,7 +322,7 @@ class AiAvatarRenderService:
|
||||
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
|
||||
job.output_video_url = output_video_url
|
||||
|
||||
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧;
|
||||
# 封面:优先复用智能剪辑的 MediaKit 抽帧 + 质量评分选最佳帧(支持 drawtext 标题叠加);
|
||||
# MediaKit 不可用时回退到 FFmpeg 已按 cover_config 抽取的 cover_path
|
||||
smart_cover_url = ""
|
||||
if output_video_url:
|
||||
@@ -299,7 +331,12 @@ class AiAvatarRenderService:
|
||||
generate_smart_cover,
|
||||
)
|
||||
|
||||
smart_cover_url = generate_smart_cover(output_video_url, job_id=job_id, max_frames=5)
|
||||
smart_cover_url = generate_smart_cover(
|
||||
output_video_url,
|
||||
job_id=job_id,
|
||||
max_frames=5,
|
||||
title_config=job.title_config,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("智能封面(MediaKit)失败,回退 FFmpeg 封面 job_id=%s", job_id, exc_info=True)
|
||||
|
||||
@@ -360,12 +397,14 @@ class AiAvatarRenderService:
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.error("渲染任务失败 [%s]: %s", job_id, exc)
|
||||
raise
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"渲染异常: {str(exc)}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.exception("渲染任务异常 [%s]", job_id)
|
||||
raise
|
||||
|
||||
def _download_video(self, url: str) -> str:
|
||||
"""下载视频到临时文件."""
|
||||
@@ -391,24 +430,73 @@ class AiAvatarRenderService:
|
||||
filter_complex: str,
|
||||
final_label: Optional[str],
|
||||
output_path: str,
|
||||
) -> str:
|
||||
"""构建 FFmpeg 命令."""
|
||||
# 输入文件
|
||||
inputs = f"-i {input_video}"
|
||||
) -> list[str]:
|
||||
"""构建 FFmpeg 命令(list 形式,shell=False).
|
||||
|
||||
根因修复 #1798 P0:OSS 预签名 URL 含 `&Expires=...&Signature=...` 特殊字符,
|
||||
os.system(shell=True) 会把 `&` 解释为后台命令分隔符,导致 -filter_complex 被
|
||||
当成独立命令报 sh: -filter_complex: not found(exit 127 → Python 32512)。
|
||||
list + shell=False 彻底规避 shell 转义问题。
|
||||
"""
|
||||
cmd: list[str] = ["ffmpeg", "-i", input_video]
|
||||
for seg in b_roll_segments:
|
||||
asset_url = seg.get("asset_url", "")
|
||||
if asset_url:
|
||||
inputs += f" -i {asset_url}"
|
||||
cmd.extend(["-i", asset_url])
|
||||
|
||||
# 滤镜
|
||||
if filter_complex and final_label:
|
||||
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
|
||||
cmd.extend(["-filter_complex", filter_complex, "-map", f"[{final_label}]"])
|
||||
elif filter_complex:
|
||||
filter_arg = f'-filter_complex "{filter_complex}"'
|
||||
else:
|
||||
filter_arg = ""
|
||||
cmd.extend(["-filter_complex", filter_complex])
|
||||
|
||||
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset veryfast -crf 23 -y {output_path}"
|
||||
cmd.extend(
|
||||
[
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-y",
|
||||
output_path,
|
||||
]
|
||||
)
|
||||
return cmd
|
||||
|
||||
def _build_cover_extract_cmd(
|
||||
self,
|
||||
*,
|
||||
cover_config: dict[str, Any],
|
||||
input_video: str,
|
||||
output_path: str,
|
||||
) -> list[str]:
|
||||
"""构建封面截帧 FFmpeg 命令(list 形式,shell=False)."""
|
||||
if not cover_config or not isinstance(cover_config, dict):
|
||||
timestamp = 0.0
|
||||
width = 0
|
||||
height = 0
|
||||
else:
|
||||
timestamp = cover_config.get("timestamp", 0.0)
|
||||
width = cover_config.get("width", 0)
|
||||
height = cover_config.get("height", 0)
|
||||
|
||||
cmd: list[str] = [
|
||||
"ffmpeg",
|
||||
"-ss",
|
||||
str(timestamp),
|
||||
"-i",
|
||||
input_video,
|
||||
"-frames:v",
|
||||
"1",
|
||||
]
|
||||
if width > 0 and height > 0:
|
||||
vf = (
|
||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2"
|
||||
)
|
||||
cmd.extend(["-vf", vf])
|
||||
cmd.extend(["-y", output_path])
|
||||
return cmd
|
||||
|
||||
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
|
||||
"""上传文件到 OSS,返回 URL.
|
||||
|
||||
@@ -156,6 +156,7 @@ def tts_synthesize_and_submit(
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav", # CosyVoice 部分接口返回 audio/x-wav,与 audio/wav 等价(RIFF/WAVE)
|
||||
"audio/mp4",
|
||||
"audio/x-m4a",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user