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
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:
@@ -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
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
|
||||
/* ── 标题字体选项 ── */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 全部通过 ✅ ==="
|
||||
@@ -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)')
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user