Merge branch 'develop' into fix/smart-match-empty-results
AI Code Review / AI Code Review (pull_request) Successful in 2m11s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m21s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m12s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m20s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m53s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m0s
CI/CD Pipeline / CI Gate (pull_request) Successful in 7s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 362h28m50s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 362h28m52s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 362h28m54s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 362h28m56s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 362h28m58s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 362h29m0s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 362h29m0s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 362h29m2s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 362h29m6s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 362h29m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 362h29m8s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 362h29m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 362h29m4s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 362h29m4s
CI/CD Pipeline / Validate - Security (pull_request) Failing after 362h29m5s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 362h29m6s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 362h29m13s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 363h3m9s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 363h3m13s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 363h3m15s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 363h3m19s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Failing after 363h3m21s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 363h3m23s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 363h3m27s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 363h3m19s

This commit is contained in:
2026-09-01 12:31:18 +08:00
25 changed files with 637 additions and 67 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ name: PR Auto Scan
# 作为短作业模式的兜底,防止事件驱动遗漏
on:
schedule:
- cron: "*/10 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
- cron: "*/15 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
workflow_dispatch:
permissions:
@@ -37,6 +37,9 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, s
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
@@ -411,7 +414,25 @@ def _get_template_segments(
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
try:
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
direct_configs = direct_repo.list_by_template(template_id)
if direct_configs:
result = []
for cc in direct_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表)
try:
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -338,12 +338,15 @@ const GeneratePage: React.FC = () => {
size: titleSettings.size,
font: titleSettings.font,
color: titleSettings.color,
position: titleSettings.position as "top" | "center" | "bottom",
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
posX: titleSettings.posX,
posY: titleSettings.posY,
}}
onTitlePositionChange={styleUpdaters.updateTitlePosition}
/>
)}
{currentStep >= 5 && generated && finalVideo && (
@@ -32,12 +32,15 @@ interface FrontendPreviewPlayerProps {
size: number
font: string
color: string
position: "top" | "center" | "bottom"
position: "top" | "center" | "bottom" | "custom"
bold?: boolean
italic?: boolean
stroke?: boolean
shadow?: boolean
posX?: number | null
posY?: number | null
}
onTitlePositionChange?: (posX: number, posY: number) => void
}
function formatTime(seconds: number): string {
@@ -105,6 +108,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
serverClips,
voiceAudioUrl,
titleSettings,
onTitlePositionChange,
}) => {
const segments = useMemo(
() => buildPlaybackSegments(assets, template, serverClips),
@@ -126,6 +130,55 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
return { width: 1080, height: 1920 }
}
})()
const customTitleXPct =
titleSettings?.posX != null && playRes.width > 0
? (titleSettings.posX / playRes.width) * 100
: null
const customTitleYPct =
titleSettings?.posY != null && playRes.height > 0
? (titleSettings.posY / playRes.height) * 100
: null
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
const draggingTitleRef = useRef(false)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
;(e.target as Element).setPointerCapture(e.pointerId)
draggingTitleRef.current = true
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
},
[onTitlePositionChange],
)
const handleTitlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !onTitlePositionChange || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const posX = Math.round((relX / rect.width) * playRes.width)
const posY = Math.round((relY / rect.height) * playRes.height)
onTitlePositionChange(posX, posY)
},
[onTitlePositionChange, playRes.width, playRes.height],
)
const handleTitlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
try {
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
}
} catch {
/* ignore */
}
}, [])
const playerContainerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(0)
useEffect(() => {
@@ -524,15 +577,34 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<div
style={{
position: "absolute",
left: `${titleSidePct}%`,
right: `${titleSidePct}%`,
textAlign: "center",
...(titleSettings.position === "top"
? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: `${titleBottomPct}%` }),
...(customTitleXPct != null && customTitleYPct != null
? {
left: `${customTitleXPct}%`,
top: `${customTitleYPct}%`,
transform: "translate(-50%, -50%)",
textAlign: "center" as const,
}
: {
left: `${titleSidePct}%`,
right: `${titleSidePct}%`,
textAlign: "center" as const,
...(titleSettings.position === "top"
? { top: `${titleTopPct}%` }
: titleSettings.position === "center"
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
cursor: onTitlePositionChange ? "grab" : "default",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
padding: "8px 12px",
boxShadow: "inset 0 0 0 16px transparent",
}}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
onPointerCancel={handleTitlePointerUp}
>
<span
style={{
@@ -23,7 +23,7 @@ const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange
onClick={() => onModeChange("auto")}
type="button"
>
选择视频库自动匹配
AI智能匹配
</button>
</div>
)
@@ -38,9 +38,8 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
className="xx-title-preset-preview-text"
style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
>
标题
T
</span>
<span className="xx-title-preset-card-label">{p.label}</span>
</button>
)
})}
+1
View File
@@ -42,6 +42,7 @@ export const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
{ value: "custom", label: "自定义" },
]
/* ── 标题字体选项 ── */
+5 -15
View File
@@ -1733,8 +1733,8 @@
/* 标题预设卡片网格 */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
}
.xx-title-preset-card {
@@ -1742,7 +1742,8 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 14px 8px;
aspect-ratio: 1 / 1;
padding: 4px;
background: var(--bg-secondary);
border: 2px solid transparent;
border-radius: var(--radius-sm);
@@ -1762,21 +1763,10 @@
}
.xx-title-preset-preview-text {
line-height: 1.4;
margin-bottom: 6px;
line-height: 1;
user-select: none;
}
.xx-title-preset-card-label {
font-size: 11px;
color: var(--text-secondary);
}
.xx-title-preset-card.active .xx-title-preset-card-label {
color: var(--primary-color);
font-weight: 500;
}
/* 样式按钮组 */
.xx-style-btns {
display: flex;
@@ -20,13 +20,14 @@ const SECONDS_PER_ASSET = 15
/**
* 根据模板 segments 计算所需素材数量上限。
* 取每个 segment 的 duration_min 之和作为目标视频总时长,
* 再按 15 秒/素材估算需要多少个素材;结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
* 再按 15 秒/素材估算需要多少个素材,且保证不少于片段数(每个片段至少 1 个素材);
* 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
*/
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
if (!segments || segments.length === 0) return DEFAULT_LIMIT
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
if (totalSeconds <= 0) return DEFAULT_LIMIT
const limit = Math.ceil(totalSeconds / SECONDS_PER_ASSET)
const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET))
return Math.max(1, Math.min(limit, 200))
}
@@ -233,7 +233,7 @@ export function useCanvasPlayer(
fontSize: number
fontFamily: string
color: string
position: "top" | "center" | "bottom"
position: "top" | "center" | "bottom" | "custom"
bold?: boolean
stroke?: boolean
shadow?: boolean
@@ -27,6 +27,8 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = {
stroke: true,
shadow: false,
color: "#ffffff",
posX: null,
posY: null,
}
export interface GenerateFormState {
@@ -117,6 +117,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
...(props.titleSettings.position === "custom" &&
props.titleSettings.posX != null &&
props.titleSettings.posY != null
? {
pos_x: Math.round(props.titleSettings.posX),
pos_y: Math.round(props.titleSettings.posY),
}
: {}),
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
@@ -46,7 +46,16 @@ export function useTitleStyleUpdaters({
const updatePosition = useCallback(
(position: string) => {
onTitleSettingsChange({ ...titleSettings, position })
// 切回三档预设时清掉自定义坐标
onTitleSettingsChange({ ...titleSettings, position, posX: null, posY: null })
},
[titleSettings, onTitleSettingsChange],
)
/** 拖拽更新自定义位置(由预览播放器调用) */
const updateTitlePosition = useCallback(
(posX: number, posY: number) => {
onTitleSettingsChange({ ...titleSettings, position: "custom", posX, posY })
},
[titleSettings, onTitleSettingsChange],
)
@@ -111,6 +120,7 @@ export function useTitleStyleUpdaters({
updateTitle,
toggleAiAutoSelect,
updatePosition,
updateTitlePosition,
updateFont,
updateSize,
updateColor,
+3
View File
@@ -16,6 +16,9 @@ export interface TitleSettings {
stroke: boolean
shadow: boolean
color: string
/** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */
posX: number | null
posY: number | null
}
/* ── 智能匹配结果 ── */
@@ -11,7 +11,7 @@ import {
} from "@ant-design/icons"
import type { ProductItem } from "../types"
import { statusConfig, reviewStatusConfig } from "../constants"
import { formatTime } from "../utils"
import { formatTime, formatSize } from "../utils"
interface ProductCardProps {
product: ProductItem
@@ -210,11 +210,20 @@ export const ProductCard: React.FC<ProductCardProps> = ({
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
<span className="xx-product-date">{product.date}</span>
</div>
{product.duplicateRate > 0 && (
<span className={`xx-product-dup-rate ${dupClass}`}>
查重率:{product.duplicateRate.toFixed(1)}%
<div className="xx-product-meta-grid">
<span className="xx-product-meta-item">分辨率:{product.resolution || "-"}</span>
<span className="xx-product-meta-item">
时长:{product.duration > 0 ? formatTime(product.duration) : "-"}
</span>
)}
<span className="xx-product-meta-item">大小:{formatSize(product.fileSize)}</span>
<span
className={`xx-product-meta-item xx-product-dup-rate${
product.duplicateRate > 0 ? ` ${dupClass}` : ""
}`}
>
查重率:{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
</span>
</div>
</div>
{/* 操作按钮 */}
+17 -2
View File
@@ -276,11 +276,11 @@
background-position: center;
}
/* 内联视频播放器 */
/* 内联视频播放器(cover 填满容器,竖屏视频不留左右空白) */
.xx-product-thumb-video {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
display: block;
}
@@ -347,6 +347,21 @@
gap: var(--space-xs);
}
/* 卡片信息网格:分辨率/时长 一行,大小/查重率 一行 */
.xx-product-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px var(--space-sm);
}
.xx-product-meta-item {
font-size: var(--font-size-xs);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 状态标签 */
.xx-product-status {
padding: 2px 10px;
+50 -1
View File
@@ -256,6 +256,42 @@ def _wrap_title_text(
return "\\N".join(wrapped_segments)
def _parse_title_position(
title_config: dict[str, Any],
video_width: int,
video_height: int,
) -> tuple[int, int] | None:
"""解析标题自由拖拽坐标 pos_x/pos_y(PlayRes 像素坐标系)。
要求两个字段同时存在、可转 int,且落在 [0, video_width] × [0, video_height]
闭区间内。任一条件不满足返回 None,调用方回退 position 三档逻辑。
Args:
title_config: 标题配置 dict
video_width: PlayResX(视频宽度像素)
video_height: PlayResY(视频高度像素)
Returns:
(x, y) 整数坐标,或 None 表示不使用自由位置
"""
if "pos_x" not in title_config or "pos_y" not in title_config:
return None
raw_x = title_config["pos_x"]
raw_y = title_config["pos_y"]
# 坐标必须是 PlayRes 像素整数:bool 是 int 子类(isinstance(True,int)=True)
# 但 True/False 作坐标无意义;float 静默截断会造成拖拽位置偏差,一律按非法回退
if isinstance(raw_x, bool) or isinstance(raw_y, bool):
return None
if not isinstance(raw_x, int) or not isinstance(raw_y, int):
return None
x, y = raw_x, raw_y
if video_width <= 0 or video_height <= 0:
return None
if not (0 <= x <= video_width and 0 <= y <= video_height):
return None
return (x, y)
def build_ass_content(
*,
video_width: int,
@@ -343,7 +379,16 @@ def build_ass_content(
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
)
title_alignment = position_to_ass_alignment(title_config.get("position", "top"))
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
# pos_x/pos_y 为 PlayRes 坐标系像素整数(PlayResX/Y = video_width/height)。
# 合法时:TitleStyle Alignment 固定 5(\an5 中对齐,使 \pos 锚点为文本块中心),
# Dialogue 文本前注入 {\pos(x,y)}。字段缺失/非法/越界时一律回退
# position → alignment 三档逻辑,现有输出保持一字节不变。
title_pos = _parse_title_position(title_config, video_width, video_height)
title_alignment = 5 if title_pos is not None else position_to_ass_alignment(
title_config.get("position", "top")
)
styles.append(
build_ass_style(
@@ -370,6 +415,10 @@ def build_ass_content(
safe_title_text_raw = escape_ass_text(title_text)
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
# 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5)
if title_pos is not None:
safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}"
events.append(
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
)
+6 -9
View File
@@ -28,15 +28,12 @@ if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
else
CONTEXTS=(
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
# CI 优化后 job 名称(2026-08):Code Quality 拆分为 Style+Security,Type Check+Migration 合并为 Python
# 与 pr_auto_scan.py 的 REQUIRED_CONTEXTS_APPROVE 保持一致
"CI/CD Pipeline / Validate - Style (pull_request)"
"CI/CD Pipeline / Validate - Security (pull_request)"
"CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Unit Tests (pull_request)"
"CI/CD Pipeline / PR Build API Image (pull_request)"
"CI/CD Pipeline / PR Build Web Image (pull_request)"
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
)
fi
@@ -153,4 +150,4 @@ done
echo
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
exit 0
exit 0
+4 -3
View File
@@ -303,9 +303,10 @@ def main():
"CI/CD Pipeline / CI Gate (pull_request)",
]
REQUIRED_CONTEXTS_APPROVE = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
# CI 优化后 job 名称(2026-08):Code Quality 拆分为 Style+Security,Type Check+Migration 合并为 Python
"CI/CD Pipeline / Validate - Style (pull_request)",
"CI/CD Pipeline / Validate - Security (pull_request)",
"CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
FRONTEND_ONLY_CONTEXT = [
+5 -5
View File
@@ -197,8 +197,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
# 创建主数据库(xdist 模式下各 worker 会创建自己的数据库,主库作为 fallback)
echo "创建主测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
import psycopg
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
@@ -304,8 +304,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
# 清理所有以 CI_DB_NAME 开头的数据库(主库 + worker 库)
PGPASSWORD="${SHARED_PG_PASSWORD}" python3 -c "
import psycopg2
conn = psycopg2.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
import psycopg
conn = psycopg.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
@@ -344,4 +344,4 @@ python3 scripts/ci_coverage_summary.py
set -e
echo ""
echo "=== CI Integration Tests 全部通过 ✅ ==="
echo "=== CI Integration Tests 全部通过 ✅ ==="
+4 -4
View File
@@ -409,8 +409,8 @@ except:
echo "创建测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
import psycopg
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
@@ -431,8 +431,8 @@ conn.close()
# 清理数据库
echo "清理测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
import psycopg
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
+4 -4
View File
@@ -169,8 +169,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
echo "创建测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
import psycopg
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
@@ -185,8 +185,8 @@ conn.close()
echo ""
echo "清理测试数据库: $CI_DB_NAME"
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
import psycopg2
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
import psycopg
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
conn.autocommit = True
cur = conn.cursor()
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
+145
View File
@@ -602,3 +602,148 @@ class TestFontsizeCompensation:
result = build_ass_style("S", font_size=0)
parts = result.split(",")
assert int(parts[2]) >= 1
# ── 标题自由位置拖拽(工单 #1405 方案 B)──────────────────────────────────────
def _title_style_line(content: str) -> str:
return [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
def _title_dialogue_line(content: str) -> str:
return [line for line in content.splitlines() if line.startswith("Dialogue:") and "TitleStyle" in line][0]
class TestTitleFreePosition:
"""pos_x/pos_y 合法时注入 \\pos 且 Alignment=5;非法/缺失时回退原逻辑。"""
def _base_kwargs(self):
return dict(
video_width=1080,
video_height=1920,
video_duration=8.0,
title_text="测试标题",
)
def test_valid_position_injects_pos_tag_and_alignment_5(self):
content = build_ass_content(
**self._base_kwargs(),
title_config={"position": "top", "size": 36, "pos_x": 540, "pos_y": 300},
)
# Dialogue 文本前注入 {\pos(540,300)}
dialogue = _title_dialogue_line(content)
assert "{\\pos(540,300)}" in dialogue
# TitleStyle Alignment 固定 5(\an5 中对齐,\pos 锚点为文本块中心)
fields = [f.strip() for f in _title_style_line(content).split(",")]
assert fields[18] == "5"
def test_boundary_coordinates_zero_and_max_accepted(self):
"""边界值 0 和 video_width/video_height 合法(闭区间)。"""
content = build_ass_content(
**self._base_kwargs(),
title_config={"pos_x": 0, "pos_y": 1920},
)
assert "{\\pos(0,1920)}" in _title_dialogue_line(content)
content2 = build_ass_content(
**self._base_kwargs(),
title_config={"pos_x": 1080, "pos_y": 0},
)
assert "{\\pos(1080,0)}" in _title_dialogue_line(content2)
def test_no_coords_output_identical_to_before(self):
"""不传坐标 → 输出与现有断言完全一致(回归保护)。"""
content = build_ass_content(
**self._base_kwargs(),
title_config={"position": "top", "size": 36},
)
# 无 \pos 注入
assert "\\pos(" not in content
# Alignment 走 position 映射(top → 8)
fields = [f.strip() for f in _title_style_line(content).split(",")]
assert fields[18] == "8"
def test_out_of_bounds_falls_back(self):
"""越界坐标 → 回退 position 三档逻辑,输出与无坐标一致。"""
base = build_ass_content(
**self._base_kwargs(),
title_config={"position": "top", "size": 36},
)
for pos_x, pos_y in [(-1, 300), (540, -1), (1081, 300), (540, 1921), (99999, 99999)]:
content = build_ass_content(
**self._base_kwargs(),
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
)
assert "\\pos(" not in content, f"({pos_x},{pos_y}) should be rejected"
assert content == base, f"({pos_x},{pos_y}) output differs from fallback"
def test_invalid_coords_falls_back(self):
"""非法类型坐标 → 回退原逻辑。"""
base = build_ass_content(
**self._base_kwargs(),
title_config={"position": "top", "size": 36},
)
for pos_x, pos_y in [("abc", 300), (540, None), (None, None), (True, 300), (540, False), (540.5, 300.9)]:
content = build_ass_content(
**self._base_kwargs(),
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
)
assert "\\pos(" not in content, f"({pos_x!r},{pos_y!r}) should be rejected"
assert content == base, f"({pos_x!r},{pos_y!r}) output differs from fallback"
def test_only_one_coord_falls_back(self):
"""只传 pos_x 或 pos_y → 回退原逻辑。"""
base = build_ass_content(
**self._base_kwargs(),
title_config={"position": "center", "size": 36},
)
content_x = build_ass_content(
**self._base_kwargs(),
title_config={"position": "center", "size": 36, "pos_x": 540},
)
content_y = build_ass_content(
**self._base_kwargs(),
title_config={"position": "center", "size": 36, "pos_y": 300},
)
assert content_x == base
assert content_y == base
assert "\\pos(" not in content_x
assert "\\pos(" not in content_y
def test_position_three_levels_unchanged_without_coords(self):
"""无坐标时 top/center/bottom 三档 Alignment 输出不变。"""
for position, expected_align in [("top", "8"), ("center", "5"), ("bottom", "2")]:
content = build_ass_content(
**self._base_kwargs(),
title_config={"position": position, "size": 36},
)
fields = [f.strip() for f in _title_style_line(content).split(",")]
assert fields[18] == expected_align
def test_pos_overrides_position_alignment(self):
"""有合法坐标时,无论 position 是什么,Alignment 都固定为 5。"""
for position in ["top", "center", "bottom"]:
content = build_ass_content(
**self._base_kwargs(),
title_config={"position": position, "size": 36, "pos_x": 100, "pos_y": 200},
)
fields = [f.strip() for f in _title_style_line(content).split(",")]
assert fields[18] == "5"
assert "{\\pos(100,200)}" in _title_dialogue_line(content)
def test_subtitle_not_affected_by_pos(self):
"""pos_x/pos_y 只影响 Title,Subtitle 输出不变。"""
content = build_ass_content(
**self._base_kwargs(),
title_config={"pos_x": 540, "pos_y": 300},
subtitle_text="配音字幕",
subtitle_config={"position": "bottom", "size": 24},
)
sub_style = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
sub_fields = [f.strip() for f in sub_style.split(",")]
assert sub_fields[18] == "2" # bottom
sub_dialogue = [
line for line in content.splitlines() if line.startswith("Dialogue:") and "SubtitleStyle" in line
][0]
assert "\\pos(" not in sub_dialogue
@@ -0,0 +1,243 @@
"""_get_template_segments 回退路径测试.
验证三级回退链:
1. 新模板系统(tpl_svc.list_clip_configs)正常 → 直接返回
2. 新模板系统主表不存在(ValueError)→ 直接查 template_clip_configs 表兜底
3. 直接查表也失败 → 回退旧模板系统(template_segments)
4. 全部失败 → 返回空列表
覆盖 P0 修复:自建模板在 edit_templates 主表不存在但在 template_clip_configs 有记录时,
from-assets 流程不再 400。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.templates_editor.clips import _get_template_segments
TEST_TEMPLATE_ID = "tmpl-orphan-001"
DEFAULT_DUR = 5.0 # _DEFAULT_EDITOR_CLIP_DURATION
def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
"""构造 mock TemplateClipConfig 领域实体."""
cc = MagicMock()
cc.order = order
cc.min_duration = min_dur
cc.max_duration = max_dur
return cc
def _make_old_segment(segment_order: int, dur_min: float = 4.0, dur_max: float = 7.0):
"""构造 mock 旧 TemplateSegment."""
s = MagicMock()
s.segment_order = segment_order
s.duration_min = dur_min
s.duration_max = dur_max
return s
# ---------------------------------------------------------------------------
# 测试
# ---------------------------------------------------------------------------
class TestGetTemplateSegmentsFallback:
"""_get_template_segments 三级回退链."""
def test_new_system_works(self):
"""路径1:新模板系统正常返回 → 直接使用."""
configs = [_make_clip_config(0, 2.0, 6.0), _make_clip_config(1, 3.0, 9.0)]
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = configs
db = MagicMock()
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 2
assert result[0] == (0, 2.0, 6.0)
assert result[1] == (1, 3.0, 9.0)
tpl_svc.list_clip_configs.assert_called_once_with(TEST_TEMPLATE_ID)
def test_main_table_missing_direct_query_succeeds(self):
"""路径2(P0修复):主表不存在 ValueError → 直接查表成功.
模拟自建模板在 edit_templates 主表已删除/不存在,
但 template_clip_configs 表有记录。
"""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError(f"模板不存在: {TEST_TEMPLATE_ID}")
db = MagicMock()
# Mock SQLAlchemyTemplateClipConfigRepository
direct_configs = [
_make_clip_config(0, 2.0, 5.0),
_make_clip_config(1, 3.0, 7.0),
_make_clip_config(2, 4.0, 8.0),
]
with (
__import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls,
):
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = direct_configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 3
assert result[0] == (0, 2.0, 5.0)
assert result[1] == (1, 3.0, 7.0)
assert result[2] == (2, 4.0, 8.0)
mock_repo.list_by_template.assert_called_once_with(TEST_TEMPLATE_ID)
def test_main_table_missing_direct_query_empty_falls_to_old(self):
"""路径2→3:主表不存在 + 直接查表为空 → 回退旧系统."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
old_segments = [_make_old_segment(0, 3.0, 6.0)]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = [] # 新表也没记录
mock_repo_cls.return_value = mock_repo
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
) as mock_old_cls:
mock_old = MagicMock()
mock_old.list_segments.return_value = old_segments
mock_old_cls.return_value = mock_old
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 1
assert result[0] == (0, 3.0, 6.0)
def test_all_fail_returns_empty(self):
"""路径4:三级全部失败 → 返回空列表."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.side_effect = Exception("DB error")
mock_repo_cls.return_value = mock_repo
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
) as mock_old_cls:
mock_old = MagicMock()
mock_old.list_segments.return_value = [] # 旧表也空
mock_old_cls.return_value = mock_old
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert result == []
def test_direct_query_sorts_by_order(self):
"""直接查表返回的结果按 order 排序."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
# 故意乱序
configs = [
_make_clip_config(2, 5.0, 10.0),
_make_clip_config(0, 2.0, 4.0),
_make_clip_config(1, 3.0, 6.0),
]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert [r[0] for r in result] == [0, 1, 2]
assert result[0] == (0, 2.0, 4.0)
assert result[1] == (1, 3.0, 6.0)
assert result[2] == (2, 5.0, 10.0)
def test_direct_query_handles_none_durations(self):
"""直接查表时 min/max_duration 为 None → 使用默认值."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
db = MagicMock()
cc = MagicMock()
cc.order = 0
cc.min_duration = None
cc.max_duration = None
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = [cc]
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
assert len(result) == 1
# None → default (5.0), max(None or None) → default (5.0)
assert result[0] == (0, DEFAULT_DUR, DEFAULT_DUR)
def test_new_system_returns_empty_tries_direct(self):
"""新模板系统返回空列表(非异常)→ 继续尝试直接查表."""
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = [] # 空列表,非异常
db = MagicMock()
direct_configs = [_make_clip_config(0, 3.0, 6.0)]
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.list_by_template.return_value = direct_configs
mock_repo_cls.return_value = mock_repo
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
# 新系统返回空 → 不走 except → 但也没 return → 继续往下走
# 直接查表有数据 → 返回
assert len(result) == 1
assert result[0] == (0, 3.0, 6.0)
def test_existing_template_unaffected(self):
"""正常模板(主表存在)行为不变."""
configs = [_make_clip_config(0, 2.0, 5.0)]
tpl_svc = MagicMock()
tpl_svc.list_clip_configs.return_value = configs
db = MagicMock()
with __import__("unittest.mock", fromlist=["patch"]).patch(
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
) as mock_repo_cls:
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
# 直接查表不应被调用(新系统已返回)
mock_repo_cls.assert_not_called()
assert len(result) == 1
assert result[0] == (0, 2.0, 5.0)