Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32990194d4 | |||
| 11dde783b9 | |||
| ddb1a3544d | |||
| 323bd2da5e | |||
| 058bfac5c2 | |||
| 7635a20fdb | |||
| be88e49543 | |||
| 2494447d94 | |||
| 9cbbf9a6e9 | |||
| 41c1845aa1 | |||
| d11ca875c6 |
@@ -381,15 +381,34 @@ def preview_tts(
|
||||
request: TTSPreviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
voice_clone_repo=Depends(get_voice_clone_profile_repository),
|
||||
) -> TTSPreviewResponse:
|
||||
"""TTS 预览(试听)——同步合成,立即返回音频 URL。
|
||||
|
||||
用于前端预览配音效果,限制文本长度 200 字以内。
|
||||
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
|
||||
"""
|
||||
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
|
||||
actual_voice_id = request.voice_id
|
||||
profile = voice_clone_repo.get(request.voice_id)
|
||||
if profile is not None:
|
||||
# 命中克隆音色 profile — 校验归属权限
|
||||
if profile.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问该音色",
|
||||
)
|
||||
if not profile.voice_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="音色克隆尚未完成,请稍后再试",
|
||||
)
|
||||
actual_voice_id = profile.voice_id
|
||||
|
||||
try:
|
||||
result = cosyvoice_service.synthesize_speech(
|
||||
text=request.text,
|
||||
voice_id=request.voice_id,
|
||||
voice_id=actual_voice_id,
|
||||
speed=request.speed,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
|
||||
@@ -76,12 +76,59 @@ function buildPlaybackSegments(
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
template,
|
||||
videoRatio: _videoRatio,
|
||||
videoRatio,
|
||||
ready,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
|
||||
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
|
||||
const TITLE_MARGIN_TOP = 120
|
||||
const TITLE_MARGIN_BOTTOM = 60
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
const playRes = (() => {
|
||||
switch (videoRatio) {
|
||||
case "16:9":
|
||||
return { width: 1920, height: 1080 }
|
||||
case "1:1":
|
||||
return { width: 1080, height: 1080 }
|
||||
case "9:16":
|
||||
default:
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
})()
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
useEffect(() => {
|
||||
const el = playerContainerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height
|
||||
if (h > 0) setContainerHeight(h)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height > 0) setContainerHeight(rect.height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// 标题字号按容器高度与 PlayResY 的比例缩放
|
||||
const titleFontSizePx =
|
||||
containerHeight > 0
|
||||
? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight
|
||||
: (titleSettings?.size ?? 36)
|
||||
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
|
||||
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
|
||||
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
|
||||
// 描边/阴影也要按缩放比例放大
|
||||
const titleScale = containerHeight > 0 ? containerHeight / playRes.height : 1
|
||||
const titleStrokeWidth = Math.max(1, 2 * titleScale)
|
||||
const titleShadowBlur = 4 * titleScale
|
||||
const titleShadowOffset = 2 * titleScale
|
||||
|
||||
// 默认走原生 video 播放(浏览器硬件解码,独立线程,不阻塞 UI)
|
||||
// WebCodecs 仅在明确需要时启用(保留代码作为兜底)
|
||||
const useWebCodecs = false
|
||||
@@ -374,6 +421,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
@@ -434,48 +482,55 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 标题CSS叠加层 — video fallback 路径也要渲染 */}
|
||||
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
|
||||
{titleSettings?.title && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
inset: 0,
|
||||
zIndex: 5,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
...(titleSettings.position === "top"
|
||||
? { top: "10%" }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: "15%" }),
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<div
|
||||
style={{
|
||||
fontSize: titleSettings.size,
|
||||
fontFamily: titleSettings.font || "思源黑体",
|
||||
color: titleSettings.color || "#ffffff",
|
||||
fontWeight: titleSettings.bold ? 700 : 400,
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
textShadow: [
|
||||
titleSettings.shadow ? "0 2px 8px rgba(0,0,0,0.7)" : undefined,
|
||||
titleSettings.stroke
|
||||
? "1px 1px 0 rgba(0,0,0,0.5), -1px -1px 0 rgba(0,0,0,0.5), 1px -1px 0 rgba(0,0,0,0.5), -1px 1px 0 rgba(0,0,0,0.5)"
|
||||
: undefined,
|
||||
"0 1px 3px rgba(0,0,0,0.4)",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
maxWidth: "90%",
|
||||
position: "absolute",
|
||||
left: `${titleSidePct}%`,
|
||||
right: `${titleSidePct}%`,
|
||||
textAlign: "center",
|
||||
lineHeight: 1.3,
|
||||
wordBreak: "break-word",
|
||||
...(titleSettings.position === "top"
|
||||
? { top: `${titleTopPct}%` }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}}
|
||||
>
|
||||
{titleSettings.title}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${titleFontSizePx}px`,
|
||||
fontFamily: titleSettings.font || "思源黑体",
|
||||
color: titleSettings.color || "#ffffff",
|
||||
fontWeight: titleSettings.bold ? 700 : 400,
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
lineHeight: 1.3,
|
||||
wordBreak: "break-word",
|
||||
WebkitTextStroke: titleSettings.stroke
|
||||
? `${titleStrokeWidth}px #000000`
|
||||
: undefined,
|
||||
textShadow: titleSettings.shadow
|
||||
? `${titleShadowOffset}px ${titleShadowOffset}px ${titleShadowBlur}px rgba(0,0,0,0.8)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{titleSettings.title.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -40,17 +40,33 @@ interface PreviewVideoPanelProps {
|
||||
}
|
||||
|
||||
/* ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── */
|
||||
const ASS_VIDEO_HEIGHT = 720
|
||||
const ASS_TITLE_MARGIN_TOP = 60
|
||||
const ASS_TITLE_MARGIN_BOTTOM = 60
|
||||
const ASS_TITLE_MARGIN_SIDE = 40
|
||||
const TITLE_MARGIN_TOP = 120
|
||||
const TITLE_MARGIN_BOTTOM = 60
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
|
||||
function getPositionStyle(position: string): React.CSSProperties {
|
||||
const sidePercent = (ASS_TITLE_MARGIN_SIDE / 1280) * 100
|
||||
/** 根据视频比例返回后端实际渲染分辨率(PlayResX × PlayResY) */
|
||||
function getResolution(ratio: string): { width: number; height: number } {
|
||||
switch (ratio) {
|
||||
case "16:9":
|
||||
return { width: 1920, height: 1080 }
|
||||
case "1:1":
|
||||
return { width: 1080, height: 1080 }
|
||||
case "9:16":
|
||||
default:
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
}
|
||||
|
||||
function getPositionStyle(
|
||||
position: string,
|
||||
playResX: number,
|
||||
playResY: number,
|
||||
): React.CSSProperties {
|
||||
const sidePercent = (TITLE_MARGIN_SIDE / playResX) * 100
|
||||
switch (position) {
|
||||
case "bottom":
|
||||
return {
|
||||
bottom: `${(ASS_TITLE_MARGIN_BOTTOM / ASS_VIDEO_HEIGHT) * 100}%`,
|
||||
bottom: `${(TITLE_MARGIN_BOTTOM / playResY) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
@@ -66,7 +82,7 @@ function getPositionStyle(position: string): React.CSSProperties {
|
||||
case "top":
|
||||
default:
|
||||
return {
|
||||
top: `${(ASS_TITLE_MARGIN_TOP / ASS_VIDEO_HEIGHT) * 100}%`,
|
||||
top: `${(TITLE_MARGIN_TOP / playResY) * 100}%`,
|
||||
left: `${sidePercent}%`,
|
||||
right: `${sidePercent}%`,
|
||||
textAlign: "center",
|
||||
@@ -74,11 +90,16 @@ function getPositionStyle(position: string): React.CSSProperties {
|
||||
}
|
||||
}
|
||||
|
||||
function buildTitleStyle(settings: TitleSettings, containerHeight: number): React.CSSProperties {
|
||||
function buildTitleStyle(
|
||||
settings: TitleSettings,
|
||||
containerHeight: number,
|
||||
playResY: number,
|
||||
): React.CSSProperties {
|
||||
// 字号按容器高度与 PlayResY 的比例缩放,不设上限(与后端一致)
|
||||
const fontSizePx =
|
||||
containerHeight > 0
|
||||
? (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * containerHeight
|
||||
: (Math.min(settings.size, 96) / ASS_VIDEO_HEIGHT) * 400
|
||||
? (settings.size / playResY) * containerHeight
|
||||
: (settings.size / playResY) * 400
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
fontFamily: getFontFamily(settings.font),
|
||||
@@ -90,8 +111,6 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac
|
||||
wordBreak: "break-word",
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
paddingLeft: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
|
||||
paddingRight: `${(ASS_TITLE_MARGIN_SIDE / 1280) * 100}%`,
|
||||
}
|
||||
if (settings.stroke) base.WebkitTextStroke = "1px #000000"
|
||||
if (settings.shadow) base.textShadow = "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
@@ -99,7 +118,10 @@ function buildTitleStyle(settings: TitleSettings, containerHeight: number): Reac
|
||||
}
|
||||
|
||||
/** CSS 标题实时预览覆盖层 */
|
||||
const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSettings }) => {
|
||||
const TitleOverlay: React.FC<{ titleSettings: TitleSettings; videoRatio: string }> = ({
|
||||
titleSettings,
|
||||
videoRatio,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(400)
|
||||
|
||||
@@ -118,12 +140,15 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const { width: playResX, height: playResY } = getResolution(videoRatio)
|
||||
|
||||
const positionStyle = useMemo(
|
||||
() => getPositionStyle(titleSettings.position),
|
||||
[titleSettings.position],
|
||||
() => getPositionStyle(titleSettings.position, playResX, playResY),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[titleSettings.position, playResX, playResY],
|
||||
)
|
||||
const titleStyle = useMemo(
|
||||
() => buildTitleStyle(titleSettings, containerHeight),
|
||||
() => buildTitleStyle(titleSettings, containerHeight, playResY),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
containerHeight,
|
||||
@@ -134,6 +159,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
|
||||
titleSettings.italic,
|
||||
titleSettings.stroke,
|
||||
titleSettings.shadow,
|
||||
playResY,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -151,7 +177,7 @@ const TitleOverlay: React.FC<{ titleSettings: TitleSettings }> = ({ titleSetting
|
||||
}}
|
||||
>
|
||||
<div style={{ ...positionStyle, ...titleStyle, position: "absolute" }}>
|
||||
{displayTitle.split("/").map((part, i) => (
|
||||
{displayTitle.split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
@@ -266,7 +292,9 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
)}
|
||||
|
||||
{/* 标题样式实时预览层(仅在有视频时叠加) */}
|
||||
{isReady && titleSettings && <TitleOverlay titleSettings={titleSettings} />}
|
||||
{isReady && titleSettings && (
|
||||
<TitleOverlay titleSettings={titleSettings} videoRatio={videoRatio} />
|
||||
)}
|
||||
|
||||
{/* stale 遮罩:配置变更提示 */}
|
||||
{isStale && (
|
||||
|
||||
@@ -94,7 +94,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
className="xx-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={48}
|
||||
max={128}
|
||||
value={settings.size}
|
||||
onChange={(e) => onUpdateSize(Number(e.target.value))}
|
||||
/>
|
||||
|
||||
@@ -512,6 +512,9 @@ def mix_with_independent_audio(
|
||||
clip_filters.append("asetpts=PTS-STARTPTS")
|
||||
else:
|
||||
clip_filters.append("asetpts=PTS-STARTPTS")
|
||||
vol = _clip_volume(clip)
|
||||
if abs(vol - 1.0) >= 1e-6:
|
||||
clip_filters.append(f"volume={vol:.4f}")
|
||||
# aformat 归一化:concat/amix 前统一音频参数,否则不同采样率/声道会失败
|
||||
clip_filters.append(AFORMAT)
|
||||
filter_parts.append(f"[{input_idx}:a]{','.join(clip_filters)}[ma{input_idx}]")
|
||||
|
||||
@@ -824,6 +824,17 @@ class UnifiedRenderService:
|
||||
)
|
||||
audio_layer.clips.append(vo_clip)
|
||||
|
||||
# replace 模式:静音原视频音轨(main + broll 图层)
|
||||
if tts_config.overlap_mode == "replace":
|
||||
for layer in layers:
|
||||
if layer.role in ("main", "broll"):
|
||||
for clip in layer.clips:
|
||||
clip.config["volume"] = 0
|
||||
logger.info(
|
||||
"TTS replace 模式:已静音原视频音轨: plan_id=%s",
|
||||
self.plan.id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"TTS 配音已添加: plan_id=%s voice_id=%s segments=%d total_%.2fs",
|
||||
self.plan.id,
|
||||
@@ -889,6 +900,16 @@ class UnifiedRenderService:
|
||||
)
|
||||
audio_layer.clips.append(vo_clip)
|
||||
|
||||
# 配音素材库默认替换原音:静音原视频音轨(main + broll 图层)
|
||||
for layer in layers:
|
||||
if layer.role in ("main", "broll"):
|
||||
for clip in layer.clips:
|
||||
clip.config["volume"] = 0
|
||||
logger.info(
|
||||
"配音素材库:已静音原视频音轨: plan_id=%s",
|
||||
self.plan.id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"配音素材库音频已添加到 audio 图层: plan_id=%s duration=%.2fs",
|
||||
self.plan.id,
|
||||
|
||||
@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Title/Subtitle 默认边距(像素)
|
||||
TITLE_MARGIN_TOP = 60
|
||||
TITLE_MARGIN_TOP = 120
|
||||
TITLE_MARGIN_BOTTOM = 60
|
||||
TITLE_MARGIN_SIDE = 40
|
||||
|
||||
@@ -147,6 +147,8 @@ def escape_ass_text(text: str) -> str:
|
||||
Returns:
|
||||
转义后的 ASS 文本
|
||||
"""
|
||||
# 用户手动换行符(半角/全角斜杠)转为 ASS 硬换行(在自动换行之前优先处理)
|
||||
text = text.replace("/", "\\N").replace("/", "\\N")
|
||||
# 将实际换行转为 ASS 硬换行
|
||||
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
||||
# 转义大括号(ASS 用它做样式覆盖标签)
|
||||
@@ -195,26 +197,33 @@ def _wrap_title_text(
|
||||
if available_width <= 0:
|
||||
return text
|
||||
|
||||
lines: list[str] = []
|
||||
current_line = ""
|
||||
current_width = 0.0
|
||||
# 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回
|
||||
segments = text.split("\\N")
|
||||
wrapped_segments: list[str] = []
|
||||
|
||||
for ch in text:
|
||||
# CJK 字符按全角估算,其他按半角
|
||||
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
|
||||
for seg in segments:
|
||||
lines: list[str] = []
|
||||
current_line = ""
|
||||
current_width = 0.0
|
||||
|
||||
if current_width + char_width > available_width and current_line:
|
||||
for ch in seg:
|
||||
# CJK 字符按全角估算,其他按半角
|
||||
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
|
||||
|
||||
if current_width + char_width > available_width and current_line:
|
||||
lines.append(current_line)
|
||||
current_line = ch
|
||||
current_width = char_width
|
||||
else:
|
||||
current_line += ch
|
||||
current_width += char_width
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = ch
|
||||
current_width = char_width
|
||||
else:
|
||||
current_line += ch
|
||||
current_width += char_width
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
wrapped_segments.append("\\N".join(lines))
|
||||
|
||||
return "\\N".join(lines)
|
||||
return "\\N".join(wrapped_segments)
|
||||
|
||||
|
||||
def build_ass_content(
|
||||
@@ -247,6 +256,12 @@ def build_ass_content(
|
||||
title_config = title_config or {}
|
||||
subtitle_config = subtitle_config or {}
|
||||
|
||||
# ── 字段名归一化:前端传 font_size/font_color,内部用 size/color ──
|
||||
if "font_size" in title_config and "size" not in title_config:
|
||||
title_config["size"] = title_config["font_size"]
|
||||
if "font_color" in title_config and "color" not in title_config:
|
||||
title_config["color"] = title_config["font_color"]
|
||||
|
||||
# ── 兼容前端简化格式:stroke/shadow 为 boolean 时,转换为标准 dict ──
|
||||
# 前端 TitleSettings 发送 stroke=true/false, shadow=true/false
|
||||
# 后端 build_ass_style 期望 stroke={enabled, color, width}, shadow={enabled, blur, offset_x, offset_y}
|
||||
@@ -296,7 +311,7 @@ def build_ass_content(
|
||||
build_ass_style(
|
||||
"TitleStyle",
|
||||
font_name=title_config.get("font", "思源黑体"),
|
||||
font_size=min(int(title_config.get("size", 36)), 36),
|
||||
font_size=int(title_config.get("size", 36)),
|
||||
primary_color=title_color,
|
||||
outline_color=stroke_color,
|
||||
outline_width=stroke_width,
|
||||
@@ -313,7 +328,7 @@ def build_ass_content(
|
||||
|
||||
# 根据视频宽度和字号自动换行标题,防止超出画面
|
||||
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
|
||||
title_font_size = min(int(title_config.get("size", 36)), 36)
|
||||
title_font_size = int(title_config.get("size", 36))
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
|
||||
|
||||
@@ -218,6 +218,14 @@ class TestEscapeAssText:
|
||||
def test_chinese_text(self):
|
||||
assert escape_ass_text("你好世界") == "你好世界"
|
||||
|
||||
def test_slash_converted_to_newline(self):
|
||||
"""半角斜杠 / 应转为 ASS 硬换行。"""
|
||||
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
|
||||
|
||||
def test_fullwidth_slash_converted_to_newline(self):
|
||||
"""全角斜杠 / 应转为 ASS 硬换行。"""
|
||||
assert escape_ass_text("标题一/标题二") == "标题一\\N标题二"
|
||||
|
||||
def test_backslash_n_in_input(self):
|
||||
# 文本里本身有 \n 字符串(不是换行符)
|
||||
result = escape_ass_text("\\n")
|
||||
@@ -408,11 +416,53 @@ class TestBuildAssContent:
|
||||
title_text="T",
|
||||
title_config={"size": 72},
|
||||
)
|
||||
# 在TitleStyle行里查找字体大小
|
||||
# 在TitleStyle行里查找字体大小(字号上限已移除,72应原样保留)
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "36"
|
||||
assert parts[2] == "72"
|
||||
break
|
||||
|
||||
def test_title_font_size_frontend_field_alias(self):
|
||||
"""前端传 font_size 应归一化为内部 size 字段。"""
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"font_size": 48},
|
||||
)
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "48"
|
||||
break
|
||||
|
||||
def test_title_font_color_frontend_field_alias(self):
|
||||
"""前端传 font_color 应归一化为内部 color 字段。"""
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"font_color": "#FF0000"},
|
||||
)
|
||||
# 红色 → &H0000FF
|
||||
assert "&H0000FF" in result
|
||||
|
||||
def test_title_size_takes_precedence_over_font_size(self):
|
||||
"""同时传 size 和 font_size 时,size 优先。"""
|
||||
result = build_ass_content(
|
||||
video_width=1920,
|
||||
video_height=1080,
|
||||
video_duration=5.0,
|
||||
title_text="T",
|
||||
title_config={"size": 56, "font_size": 28},
|
||||
)
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "56"
|
||||
break
|
||||
|
||||
def test_title_bold(self):
|
||||
|
||||
@@ -190,6 +190,19 @@ class TestEscapeAssText:
|
||||
def test_chinese_text(self):
|
||||
assert escape_ass_text("你好世界") == "你好世界"
|
||||
|
||||
def test_slash_converted_to_newline(self):
|
||||
"""半角斜杠 / 应转为 ASS 硬换行。"""
|
||||
assert escape_ass_text("第一行/第二行") == "第一行\\N第二行"
|
||||
|
||||
def test_fullwidth_slash_converted_to_newline(self):
|
||||
"""全角斜杠 / 应转为 ASS 硬换行。"""
|
||||
assert escape_ass_text("第一行/第二行") == "第一行\\N第二行"
|
||||
|
||||
def test_mixed_slashes_and_newlines(self):
|
||||
"""斜杠和换行符都应转为硬换行。"""
|
||||
result = escape_ass_text("A/B\nC/D")
|
||||
assert result == "A\\NB\\NC\\ND"
|
||||
|
||||
|
||||
# ── 时间格式化 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -440,7 +453,7 @@ class TestBuildAssContent:
|
||||
|
||||
class TestConstants:
|
||||
def test_title_margin_top(self):
|
||||
assert TITLE_MARGIN_TOP == 60
|
||||
assert TITLE_MARGIN_TOP == 120
|
||||
|
||||
def test_title_margin_bottom(self):
|
||||
assert TITLE_MARGIN_BOTTOM == 60
|
||||
@@ -491,6 +504,35 @@ class TestWrapTitleText:
|
||||
"""字号为0时直接返回原文。"""
|
||||
assert _wrap_title_text("测试", 1080, 0) == "测试"
|
||||
|
||||
def test_preserves_explicit_newline(self):
|
||||
"""已有的 \\N 换行标记应保留,不被当普通字符算宽度。"""
|
||||
text = "第一行\\N第二行"
|
||||
result = _wrap_title_text(text, video_width=1080, font_size=48)
|
||||
assert result == text
|
||||
|
||||
def test_explicit_newline_each_segment_wraps_independently(self):
|
||||
"""\\N 分段后,每段各自自动换行。"""
|
||||
# 480px 宽,48px 字号,可用 400px,每段约8个中文字
|
||||
text = "这是第一段很长很长很长的内容\\N这是第二段也很长很长的内容"
|
||||
result = _wrap_title_text(text, video_width=480, font_size=48)
|
||||
# 应该有多个 \N:用户手动的 + 自动换行的
|
||||
assert "\\N" in result
|
||||
segments = result.split("\\N")
|
||||
# 至少3行(两段都需要换行)
|
||||
assert len(segments) >= 3
|
||||
# 验证包含两段的文字
|
||||
joined = result.replace("\\N", "")
|
||||
assert "第一段" in joined
|
||||
assert "第二段" in joined
|
||||
|
||||
def test_multiple_explicit_newlines(self):
|
||||
"""多个 \\N 分段都应保留。"""
|
||||
text = "A\\NB\\NC"
|
||||
result = _wrap_title_text(text, video_width=1080, font_size=48)
|
||||
assert result == text
|
||||
assert result.count("\\N") == 2
|
||||
|
||||
|
||||
def test_build_ass_content_integration(self):
|
||||
"""集成测试:build_ass_content 中的标题应该自动换行。"""
|
||||
long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作"
|
||||
|
||||
@@ -11,6 +11,7 @@ from video_processing.render_audio import (
|
||||
RenderContext,
|
||||
clip_effective_duration,
|
||||
clip_has_audio,
|
||||
mix_with_independent_audio,
|
||||
)
|
||||
from video_processing.unified_render_service import ResolvedClip
|
||||
|
||||
@@ -153,3 +154,93 @@ class TestRenderContext:
|
||||
"""音频缓存初始为空."""
|
||||
ctx = _make_ctx()
|
||||
assert ctx._audio_cache == {}
|
||||
|
||||
|
||||
class TestMixWithIndependentAudioVolume:
|
||||
"""测试 mix_with_independent_audio 中主视频 clip 音量滤镜是否生效。"""
|
||||
|
||||
def _make_clip(self, volume=None, clip_id="c1", duration=5.0):
|
||||
"""创建测试用 ResolvedClip。"""
|
||||
config = {}
|
||||
if volume is not None:
|
||||
config["volume"] = volume
|
||||
return ResolvedClip(
|
||||
clip_id=clip_id,
|
||||
asset_id=f"a_{clip_id}",
|
||||
local_path=Path(f"/tmp/{clip_id}.mp4"),
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=duration,
|
||||
actual_duration=duration,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def test_main_clip_volume_zero_applied_in_filter(self):
|
||||
"""volume=0 的主视频 clip 应在 filter_complex 中包含 volume=0.0000。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
ctx = _make_ctx()
|
||||
main_clip = self._make_clip(volume=0, clip_id="m1")
|
||||
captured_cmd = {}
|
||||
|
||||
def fake_run_ffmpeg(cmd):
|
||||
captured_cmd["cmd"] = cmd
|
||||
|
||||
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
|
||||
mix_with_independent_audio(
|
||||
ctx=ctx,
|
||||
main_clips=[main_clip],
|
||||
audio_clips=[],
|
||||
output_path=Path("/tmp/out.m4a"),
|
||||
video_duration=5.0,
|
||||
)
|
||||
|
||||
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
|
||||
assert "volume=0.0000" in filter_complex, f"volume filter missing in: {filter_complex}"
|
||||
|
||||
def test_main_clip_default_volume_no_filter(self):
|
||||
"""默认 volume=1.0 时不应添加 volume 滤镜。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
ctx = _make_ctx()
|
||||
main_clip = self._make_clip(clip_id="m1") # no volume set
|
||||
captured_cmd = {}
|
||||
|
||||
def fake_run_ffmpeg(cmd):
|
||||
captured_cmd["cmd"] = cmd
|
||||
|
||||
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
|
||||
mix_with_independent_audio(
|
||||
ctx=ctx,
|
||||
main_clips=[main_clip],
|
||||
audio_clips=[],
|
||||
output_path=Path("/tmp/out.m4a"),
|
||||
video_duration=5.0,
|
||||
)
|
||||
|
||||
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
|
||||
# 默认音量不应出现 volume= 滤镜
|
||||
assert "volume=" not in filter_complex, f"unexpected volume filter in: {filter_complex}"
|
||||
|
||||
def test_main_clip_partial_volume_applied(self):
|
||||
"""volume=0.5 的主视频 clip 应包含 volume=0.5000。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
ctx = _make_ctx()
|
||||
main_clip = self._make_clip(volume=0.5, clip_id="m1")
|
||||
captured_cmd = {}
|
||||
|
||||
def fake_run_ffmpeg(cmd):
|
||||
captured_cmd["cmd"] = cmd
|
||||
|
||||
with patch("video_processing.render_audio.run_ffmpeg", side_effect=fake_run_ffmpeg):
|
||||
mix_with_independent_audio(
|
||||
ctx=ctx,
|
||||
main_clips=[main_clip],
|
||||
audio_clips=[],
|
||||
output_path=Path("/tmp/out.m4a"),
|
||||
video_duration=5.0,
|
||||
)
|
||||
|
||||
filter_complex = captured_cmd["cmd"][captured_cmd["cmd"].index("-filter_complex") + 1]
|
||||
assert "volume=0.5000" in filter_complex, f"volume filter missing in: {filter_complex}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""标题渲染前后端一致性测试。
|
||||
|
||||
验证 build_ass_content 生成的 ASS 样式参数与前端 drawTitleOnCanvas.ts 一致:
|
||||
- 字号上限 36px
|
||||
- 字号不再设置上限,由前端/调用方控制
|
||||
- 描边宽度 2px
|
||||
- 阴影 blur=4, offset=2
|
||||
- boolean stroke/shadow 自动转换
|
||||
@@ -10,14 +10,16 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure packages is importable
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "packages"))
|
||||
|
||||
from domain.ass_subtitle_builder import build_ass_content, build_ass_style
|
||||
|
||||
|
||||
class TestFontSizeCap:
|
||||
"""字号上限应与前端 Math.min(settings.size, 36) 一致。"""
|
||||
class TestFontSize:
|
||||
"""字号处理:默认值与保留逻辑,不再做上限截断。"""
|
||||
|
||||
def test_default_font_size_is_36(self):
|
||||
"""无 size 字段时,默认字号应为 36。"""
|
||||
@@ -43,8 +45,8 @@ class TestFontSizeCap:
|
||||
)
|
||||
assert ",32," in content
|
||||
|
||||
def test_size_60_capped_at_36(self):
|
||||
"""size=60 应被 cap 到 36。"""
|
||||
def test_size_60_preserved(self):
|
||||
"""size=60 应原样保留(字号上限已移除)。"""
|
||||
config = {"size": 60}
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
@@ -53,14 +55,40 @@ class TestFontSizeCap:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
# 解析 Style 行的 Fontsize 字段(第3个字段,索引2)
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
font_size = int(fields[2])
|
||||
assert font_size == 36, f"字号60应被cap到36, 实际={font_size}"
|
||||
assert font_size == 60, f"字号60应原样保留, 实际={font_size}"
|
||||
|
||||
def test_font_size_alias_normalized(self):
|
||||
"""前端传 font_size 应归一化为 size。"""
|
||||
config = {"font_size": 52}
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
assert fields[2] == "52", f"font_size=52 应归一化, 实际={fields[2]}"
|
||||
|
||||
def test_font_color_alias_normalized(self):
|
||||
"""前端传 font_color 应归一化为 color。"""
|
||||
config = {"font_color": "#00FF00"}
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
# 绿色 → &H00FF00
|
||||
assert "&H00FF00" in content
|
||||
|
||||
def test_size_24_preserved(self):
|
||||
"""size=24 应原样使用(小于36,不cap)。"""
|
||||
"""size=24 应原样使用。"""
|
||||
config = {"size": 24}
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
@@ -203,7 +231,7 @@ class TestFullStyleConsistency:
|
||||
|
||||
# Fontname
|
||||
assert fields[1] == "思源黑体"
|
||||
# Fontsize = 28 (小于36,不cap)
|
||||
# Fontsize = 28
|
||||
assert fields[2] == "28"
|
||||
# Bold = -1 (True)
|
||||
assert fields[7] == "-1"
|
||||
@@ -213,3 +241,39 @@ class TestFullStyleConsistency:
|
||||
assert int(fields[17]) == 2
|
||||
# Alignment = 8 (top)
|
||||
assert int(fields[18]) == 8
|
||||
|
||||
|
||||
class TestTitleSlashNewline:
|
||||
"""用户输入 / 或 / 应触发标题换行。"""
|
||||
|
||||
def test_halfwidth_slash_in_title(self):
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="第一行/第二行",
|
||||
title_config={"size": 48},
|
||||
)
|
||||
for line in content.splitlines():
|
||||
if "Dialogue" in line and "TitleStyle" in line:
|
||||
assert "\\N" in line, f"斜杠应转为换行: {line}"
|
||||
assert "第一行" in line
|
||||
assert "第二行" in line
|
||||
break
|
||||
else:
|
||||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||||
|
||||
def test_fullwidth_slash_in_title(self):
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=10.0,
|
||||
title_text="第一行/第二行",
|
||||
title_config={"size": 48},
|
||||
)
|
||||
for line in content.splitlines():
|
||||
if "Dialogue" in line and "TitleStyle" in line:
|
||||
assert "\\N" in line, f"全角斜杠应转为换行: {line}"
|
||||
break
|
||||
else:
|
||||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||||
|
||||
@@ -81,6 +81,12 @@ class TestTTSPreviewEndpoint:
|
||||
)
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -120,6 +126,12 @@ class TestTTSPreviewEndpoint:
|
||||
)
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -158,6 +170,12 @@ class TestTTSPreviewEndpoint:
|
||||
mock_service.synthesize_speech.side_effect = CosyVoiceError("API timeout")
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -186,6 +204,12 @@ class TestTTSPreviewEndpoint:
|
||||
mock_service.synthesize_speech.side_effect = ValueError("text 不能为空")
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -213,6 +237,12 @@ class TestTTSPreviewEndpoint:
|
||||
mock_service = MagicMock()
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
@@ -239,9 +269,173 @@ class TestTTSPreviewEndpoint:
|
||||
mock_service = MagicMock()
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None # no profile found = preset voice
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "hello", "voice_id": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_preview_clone_voice_resolves_to_cosyvoice_id(self):
|
||||
"""Clone voice UUID is resolved to CosyVoice voice_id."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
from app.api.routes.tts import router
|
||||
|
||||
app.include_router(router, prefix="/tts")
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
|
||||
fake_user = MagicMock()
|
||||
fake_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: fake_user
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
|
||||
audio_url="https://x.com/cloned.mp3",
|
||||
duration=1.8,
|
||||
)
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
# Mock voice clone profile with voice_id
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.user_id = "user-1"
|
||||
mock_profile.voice_id = "cosyvoice_actual_voice_123"
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = mock_profile
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
# Frontend sends the profile UUID as voice_id
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "克隆音色测试", "voice_id": "abc123-uuid-of-profile"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["audio_url"] == "https://x.com/cloned.mp3"
|
||||
|
||||
# Verify CosyVoice was called with the resolved voice_id, not the UUID
|
||||
mock_service.synthesize_speech.assert_called_once_with(
|
||||
text="克隆音色测试",
|
||||
voice_id="cosyvoice_actual_voice_123",
|
||||
speed=1.0,
|
||||
)
|
||||
# Verify repo was queried with the UUID
|
||||
mock_clone_repo.get.assert_called_once_with("abc123-uuid-of-profile")
|
||||
|
||||
def test_preview_clone_voice_incomplete_returns_400(self):
|
||||
"""Clone profile with empty voice_id returns 400."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
from app.api.routes.tts import router
|
||||
|
||||
app.include_router(router, prefix="/tts")
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
fake_user = MagicMock()
|
||||
fake_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: fake_user
|
||||
|
||||
# Mock voice clone profile with empty voice_id (clone not finished)
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.user_id = "user-1"
|
||||
mock_profile.voice_id = ""
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = mock_profile
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "测试未完成克隆", "voice_id": "abc123-uuid"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "音色克隆尚未完成" in resp.json()["detail"]
|
||||
|
||||
def test_preview_preset_voice_passthrough(self):
|
||||
"""Preset voice ID (not a profile UUID) passes through unchanged."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
from app.api.routes.tts import router
|
||||
|
||||
app.include_router(router, prefix="/tts")
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
|
||||
fake_user = MagicMock()
|
||||
fake_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: fake_user
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.synthesize_speech.return_value = FakeSynthesizeResult(
|
||||
audio_url="https://x.com/preset.mp3",
|
||||
duration=2.0,
|
||||
)
|
||||
app.dependency_overrides[get_cosyvoice_service] = lambda: mock_service
|
||||
|
||||
# Mock repo returns None (preset voice, not a clone profile)
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = None
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "预设音色测试", "voice_id": "longxiaoxia_v3"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify CosyVoice was called with the original preset voice_id
|
||||
mock_service.synthesize_speech.assert_called_once_with(
|
||||
text="预设音色测试",
|
||||
voice_id="longxiaoxia_v3",
|
||||
speed=1.0,
|
||||
)
|
||||
|
||||
def test_preview_clone_voice_wrong_user_returns_403(self):
|
||||
"""Accessing another user's clone profile returns 403."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
from app.api.routes.tts import router
|
||||
|
||||
app.include_router(router, prefix="/tts")
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import get_voice_clone_profile_repository
|
||||
|
||||
fake_user = MagicMock()
|
||||
fake_user.user.id = "user-1"
|
||||
app.dependency_overrides[get_current_user] = lambda: fake_user
|
||||
|
||||
# Mock profile belonging to a different user
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.user_id = "user-2"
|
||||
mock_profile.voice_id = "cosyvoice_voice_xyz"
|
||||
mock_clone_repo = MagicMock()
|
||||
mock_clone_repo.get.return_value = mock_profile
|
||||
app.dependency_overrides[get_voice_clone_profile_repository] = lambda: mock_clone_repo
|
||||
|
||||
client = self._make_client(app)
|
||||
resp = client.post(
|
||||
"/tts/preview",
|
||||
json={"text": "越权测试", "voice_id": "other-user-profile-uuid"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "无权访问该音色" in resp.json()["detail"]
|
||||
|
||||
Reference in New Issue
Block a user