Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00067907f2 | |||
| 1667b3878d | |||
| 89cd853bbe | |||
| 3bbe87b5c8 | |||
| 0c216bc545 | |||
| f0dce3d41d | |||
| c2fe02cf06 | |||
| 195339d0f8 | |||
| 29c0d76677 | |||
| 608e200c0c | |||
| b12bb24d08 | |||
| 7cdd06802d |
@@ -169,8 +169,22 @@ def _writeback_edit_plan_config(
|
||||
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
|
||||
merged = dict(current_config)
|
||||
merged["generation_task_id"] = task_id
|
||||
|
||||
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
|
||||
if title_config:
|
||||
old_title_config = merged.get("title_config", {}) or {}
|
||||
old_title_text = (old_title_config.get("text") or "").strip()
|
||||
new_title_text = (title_config.get("text") or "").strip()
|
||||
if old_title_text != new_title_text:
|
||||
# 标题变化,清除旧封面
|
||||
if "cover" in merged:
|
||||
del merged["cover"]
|
||||
logger.info(
|
||||
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
|
||||
plan_id, old_title_text, new_title_text,
|
||||
)
|
||||
merged["title_config"] = title_config
|
||||
|
||||
plan_model.config = merged
|
||||
db.commit()
|
||||
logger.info(
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
@@ -26,6 +28,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.domain.plan_generator_utils import _calc_random_start_time
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
from .schemas import (
|
||||
@@ -351,6 +354,134 @@ def batch_delete_editor_clips(
|
||||
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
|
||||
|
||||
|
||||
|
||||
def _recommended_time_conflicts(
|
||||
start: float,
|
||||
duration: float,
|
||||
used: list[tuple[float, float]],
|
||||
) -> bool:
|
||||
"""检查推荐起始时间是否与已使用时间段冲突."""
|
||||
end = start + duration
|
||||
for used_start, used_end in used:
|
||||
if start < used_end and end > used_start:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_mediakit_recommendations(
|
||||
asset_ids: list[str],
|
||||
asset_repo,
|
||||
) -> dict[str, float]:
|
||||
"""调用 MediaKit 视频理解,获取智能选片推荐起始时间.
|
||||
|
||||
尝试让 MediaKit 分析视频内容,返回每个素材的推荐起始时间。
|
||||
任何异常都优雅降级,返回空字典(调用方降级到随机选择)。
|
||||
"""
|
||||
try:
|
||||
client = get_mediakit_client()
|
||||
if not client.is_available:
|
||||
logger.info("MediaKit 未配置,使用随机起始时间")
|
||||
return {}
|
||||
|
||||
storage = get_storage_service()
|
||||
|
||||
video_urls: list[str] = []
|
||||
valid_asset_ids: list[str] = []
|
||||
for asset_id in asset_ids[:10]:
|
||||
asset = asset_repo.get(asset_id)
|
||||
if not asset or not getattr(asset, "storage_key", None):
|
||||
continue
|
||||
mime = getattr(asset, "mime_type", "")
|
||||
if not mime.startswith("video/"):
|
||||
continue
|
||||
try:
|
||||
url = storage.get_download_url(asset.storage_key)
|
||||
if url:
|
||||
video_urls.append(url)
|
||||
valid_asset_ids.append(asset_id)
|
||||
except Exception as e:
|
||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
if not video_urls:
|
||||
return {}
|
||||
|
||||
prompt = (
|
||||
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
|
||||
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
|
||||
'请严格以JSON数组格式返回,不要包含其他文字:'
|
||||
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
|
||||
)
|
||||
|
||||
contents = client.analyze_videos(
|
||||
video_urls=video_urls,
|
||||
prompt=prompt,
|
||||
level="Economy",
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=15,
|
||||
)
|
||||
|
||||
if not contents:
|
||||
logger.info("MediaKit 分析无结果,降级为随机选择")
|
||||
return {}
|
||||
|
||||
# 按索引映射结果:contents[i] 对应 valid_asset_ids[i]
|
||||
recommendations: dict[str, float] = {}
|
||||
for idx, content_text in enumerate(contents):
|
||||
if idx >= len(valid_asset_ids):
|
||||
break
|
||||
asset_id = valid_asset_ids[idx]
|
||||
if not content_text:
|
||||
continue
|
||||
|
||||
# 尝试从文本中提取 JSON
|
||||
parsed = False
|
||||
# 尝试直接解析
|
||||
try:
|
||||
data = json.loads(content_text.strip())
|
||||
if isinstance(data, list) and data:
|
||||
for item in data:
|
||||
if isinstance(item, dict) and "recommended_start_time" in item:
|
||||
recommendations[asset_id] = float(item["recommended_start_time"])
|
||||
parsed = True
|
||||
break
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 尝试从 markdown 代码块中提取 JSON
|
||||
if not parsed:
|
||||
json_match = re.search(r"\[\s*(\{.*?\})\s*\]", content_text, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
item = json.loads(json_match.group(1))
|
||||
if isinstance(item, dict) and "recommended_start_time" in item:
|
||||
recommendations[asset_id] = float(item["recommended_start_time"])
|
||||
parsed = True
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 尝试正则提取
|
||||
if not parsed:
|
||||
time_match = re.search(
|
||||
r'recommended_start_time["\s:]+([\d.]+)', content_text
|
||||
)
|
||||
if time_match:
|
||||
try:
|
||||
recommendations[asset_id] = float(time_match.group(1))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if recommendations:
|
||||
logger.info("MediaKit 智能选片推荐: %s", recommendations)
|
||||
else:
|
||||
logger.info("MediaKit 结果解析失败,降级为随机选择")
|
||||
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets_editor(
|
||||
template_id: str,
|
||||
@@ -386,7 +517,17 @@ def create_clips_from_assets_editor(
|
||||
existing_clips_list = plan_svc.list_clips(plan_id)
|
||||
next_order = max((c.order for c in existing_clips_list), default=-1) + 1
|
||||
|
||||
# 从已有片段中构建已使用时间段,避免跨任务重复使用同一段素材区域
|
||||
used_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
for _clip in existing_clips_list:
|
||||
if _clip.asset_id and _clip.start_time is not None and _clip.duration is not None:
|
||||
used_segments.setdefault(_clip.asset_id, []).append(
|
||||
(float(_clip.start_time), float(_clip.start_time) + float(_clip.duration))
|
||||
)
|
||||
|
||||
# 尝试获取 MediaKit 智能选片推荐
|
||||
mediakit_recommendations = _get_mediakit_recommendations(unique_asset_ids, asset_repo)
|
||||
|
||||
clips = []
|
||||
|
||||
for i in range(required_count):
|
||||
@@ -400,8 +541,30 @@ def create_clips_from_assets_editor(
|
||||
else:
|
||||
clip_duration = _DEFAULT_EDITOR_CLIP_DURATION
|
||||
|
||||
# 计算随机 start_time,避开已使用的时间段
|
||||
start_time = _calc_random_start_time(asset_id, clip_duration, asset_durations, used_segments)
|
||||
# 优先使用 MediaKit 推荐的起始时间,冲突时降级为随机
|
||||
recommended_start = mediakit_recommendations.get(asset_id)
|
||||
if (
|
||||
recommended_start is not None
|
||||
and recommended_start + clip_duration <= asset_durations.get(asset_id, float("inf"))
|
||||
and not _recommended_time_conflicts(
|
||||
recommended_start, clip_duration, used_segments.get(asset_id, [])
|
||||
)
|
||||
):
|
||||
start_time = recommended_start
|
||||
logger.info(
|
||||
"使用MediaKit推荐起始时间: asset_id=%s start_time=%.2f",
|
||||
asset_id, start_time,
|
||||
)
|
||||
else:
|
||||
if recommended_start is not None:
|
||||
logger.info(
|
||||
"MediaKit推荐时间冲突或越界,降级为随机: asset_id=%s recommended=%.2f",
|
||||
asset_id, recommended_start,
|
||||
)
|
||||
# 随机选择起始时间,避开已使用的时间段
|
||||
start_time = _calc_random_start_time(
|
||||
asset_id, clip_duration, asset_durations, used_segments
|
||||
)
|
||||
if start_time is None:
|
||||
# 素材时长信息缺失,无法计算随机起始时间
|
||||
raise HTTPException(
|
||||
|
||||
@@ -185,15 +185,16 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 2: select material
|
||||
// Step 2: select material (card grid UI)
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..")
|
||||
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中
|
||||
const materialCard = page.getByText(sourceFileName).locator("..")
|
||||
await expect(materialCard).toBeVisible({ timeout: 10_000 })
|
||||
await materialCard.click()
|
||||
// 验证选中:卡片应出现勾选标记 ✓
|
||||
await expect(materialCard.getByText("✓")).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
|
||||
@@ -37,14 +37,33 @@ async function loginWithRetry(
|
||||
})
|
||||
}
|
||||
|
||||
async function registerWithRetry(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
username: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
maxRetries = 2,
|
||||
) {
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password, username, display_name: displayName },
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[register] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password, username, display_name: displayName },
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
})
|
||||
const reg = await registerWithRetry(request, email, username, PASSWORD, `E2E ${label}`)
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
} = formState
|
||||
|
||||
/* ── 标题样式回调 ── */
|
||||
@@ -263,6 +265,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
totalVideoDuration={totalVideoDuration}
|
||||
onServerClipsChange={setServerClips}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={setVoiceMode}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
@@ -301,6 +304,7 @@ const GeneratePage: React.FC = () => {
|
||||
template={currentTemplate}
|
||||
videoRatio={videoRatio}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
titleSettings={{
|
||||
title: titleSettings.title,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
|
||||
|
||||
@@ -24,6 +25,7 @@ interface FrontendPreviewPlayerProps {
|
||||
template: EditingTemplate | null
|
||||
videoRatio: string
|
||||
ready: boolean
|
||||
serverClips?: EditPlanClip[]
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
title: string
|
||||
@@ -50,9 +52,31 @@ function formatTime(seconds: number): string {
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
template: EditingTemplate | null,
|
||||
serverClips?: EditPlanClip[],
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length) return []
|
||||
|
||||
// Build asset lookup map
|
||||
const assetMap = new Map(assets.map((a) => [a.id, a]))
|
||||
|
||||
// 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致
|
||||
if (serverClips && serverClips.length > 0) {
|
||||
const segments: PlaybackSegment[] = []
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
const startTime = clip.start_time || 0
|
||||
const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration)
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: 本地构建片段(与旧行为一致)
|
||||
const templateSegments = template?.segments || []
|
||||
const segments: PlaybackSegment[] = []
|
||||
|
||||
@@ -78,10 +102,14 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
template,
|
||||
videoRatio,
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
}) => {
|
||||
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips),
|
||||
[assets, template, serverClips],
|
||||
)
|
||||
|
||||
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
|
||||
const TITLE_MARGIN_TOP = 120
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
@@ -55,6 +56,7 @@ export interface GenerateStepContentProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
totalVideoDuration?: number
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
@@ -114,6 +116,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
totalVideoDuration,
|
||||
onServerClipsChange,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
clonedVoices,
|
||||
@@ -155,6 +158,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
templateSegments={templateSegments}
|
||||
onServerClipsChange={onServerClipsChange}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useStep2Materials } from "../hooks/useStep2Materials"
|
||||
import MaterialModeTabs from "./material/MaterialModeTabs"
|
||||
import ManualMaterialList from "./material/ManualMaterialList"
|
||||
@@ -20,6 +21,8 @@ interface Step2MaterialSelectProps {
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
}
|
||||
|
||||
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 手动选择素材列表
|
||||
* 手动选择素材列表 — 竖屏 9:16 卡片网格
|
||||
* 交互:默认显示封面,点击播放按钮播放,播放中隐藏按钮,点击视频区域暂停
|
||||
*/
|
||||
import React, { useRef, useCallback } from "react"
|
||||
import React, { useRef, useState, useCallback } from "react"
|
||||
import { Typography } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
@@ -14,39 +15,238 @@ interface ManualMaterialListProps {
|
||||
onToggle: (materialId: string) => void
|
||||
}
|
||||
|
||||
/** 秒数格式化为 mm:ss */
|
||||
const fmtDuration = (seconds?: number): string => {
|
||||
if (!seconds && seconds !== 0) return "--:--"
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 单个素材卡片 */
|
||||
const MaterialCard: React.FC<{
|
||||
asset: AssetItem
|
||||
checked: boolean
|
||||
onToggle: () => void
|
||||
}> = ({ asset, checked, onToggle }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const isVideo = asset.mime_type?.startsWith("video/") ?? false
|
||||
const thumbSrc = asset.thumbnail_url || undefined
|
||||
|
||||
const handlePlayToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const video = videoRef.current
|
||||
if (!video || !isVideo) return
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
} else {
|
||||
video.play().catch(() => {})
|
||||
setIsPlaying(true)
|
||||
}
|
||||
},
|
||||
[isPlaying, isVideo],
|
||||
)
|
||||
|
||||
const handleVideoEnded = useCallback(() => {
|
||||
setIsPlaying(false)
|
||||
}, [])
|
||||
|
||||
const handleCardClick = useCallback(() => {
|
||||
// 如果视频正在播放,点击卡片空白区域暂停视频
|
||||
if (isPlaying) {
|
||||
const video = videoRef.current
|
||||
if (video) {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
onToggle()
|
||||
}, [isPlaying, onToggle])
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
style={{
|
||||
position: "relative",
|
||||
aspectRatio: "9 / 16",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
border: checked ? "2px solid var(--primary-color, #4f46e5)" : "2px solid transparent",
|
||||
boxShadow: checked ? "0 0 0 2px rgba(79, 70, 229, 0.2)" : "0 1px 3px rgba(0, 0, 0, 0.1)",
|
||||
background: "#1e293b",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
{/* 视频元素 */}
|
||||
{isVideo && asset.file_url ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={asset.file_url}
|
||||
poster={thumbSrc}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onEnded={handleVideoEnded}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
) : thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={asset.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #334155, #1e293b)",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: 28,
|
||||
}}
|
||||
>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部渐变遮罩 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "50%",
|
||||
background: "linear-gradient(0deg, rgba(0,0,0,0.6) 0%, transparent 100%)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 播放按钮 — 播放中隐藏 */}
|
||||
{!isPlaying && (
|
||||
<div
|
||||
onClick={handlePlayToggle}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(99, 102, 241, 0.85)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 3,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件名(左下角) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
left: 6,
|
||||
right: 50,
|
||||
color: "white",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
textShadow: "0 1px 2px rgba(0,0,0,0.5)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{asset.name}
|
||||
</div>
|
||||
|
||||
{/* 时长(右下角) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
color: "white",
|
||||
padding: "1px 5px",
|
||||
borderRadius: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{fmtDuration(asset.duration)}
|
||||
</div>
|
||||
|
||||
{/* 选中勾选标记(左上角) */}
|
||||
{checked && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
left: 6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-color, #4f46e5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
materials,
|
||||
materialsLoading,
|
||||
selectedMaterials,
|
||||
onToggle,
|
||||
}) => {
|
||||
// 追踪当前正在播放的视频元素,确保同时只有一个视频播放
|
||||
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget
|
||||
// 暂停之前正在播放的视频(检查是否仍在 DOM 中)
|
||||
if (
|
||||
activeVideoRef.current &&
|
||||
activeVideoRef.current !== video &&
|
||||
document.body.contains(activeVideoRef.current)
|
||||
) {
|
||||
activeVideoRef.current.pause()
|
||||
activeVideoRef.current.currentTime = 0
|
||||
}
|
||||
activeVideoRef.current = video
|
||||
video.play().catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleVideoMouseLeave = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
if (activeVideoRef.current === video) {
|
||||
activeVideoRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
@@ -56,145 +256,21 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
暂无素材,请先在视频库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.items.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id)
|
||||
const isVideo = m.mime_type?.startsWith("video/") ?? false
|
||||
const thumbSrc = m.thumbnail_url || undefined
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(m.id)}
|
||||
style={{
|
||||
accentColor: "var(--primary-color, #4f46e5)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{/* 缩略图预览 48×48 */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
background: "#e2e8f0",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isVideo && m.file_url ? (
|
||||
<video
|
||||
src={m.file_url}
|
||||
poster={m.thumbnail_url || undefined}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
onMouseEnter={handleVideoMouseEnter}
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
) : thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={m.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.nextElementSibling as HTMLElement | null
|
||||
if (fallback) fallback.style.display = "flex"
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!thumbSrc && !isVideo && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
🎵
|
||||
</span>
|
||||
)}
|
||||
{!thumbSrc && isVideo && !m.file_url && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</span>
|
||||
)}
|
||||
{/* img onError 时显示的 fallback(初始隐藏) */}
|
||||
{thumbSrc && !(isVideo && m.file_url) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "none",
|
||||
}}
|
||||
>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{m.mime_type?.split("/")?.[1]?.toUpperCase() ?? "FILE"}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{materials.items.map((asset) => (
|
||||
<MaterialCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
checked={selectedMaterials.includes(asset.id)}
|
||||
onToggle={() => onToggle(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -194,16 +194,16 @@
|
||||
============================================================ */
|
||||
.xx-choice-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-choice-item {
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
transition: 0.18s ease;
|
||||
text-align: center;
|
||||
@@ -219,18 +219,20 @@
|
||||
}
|
||||
|
||||
.xx-choice-thumb {
|
||||
height: 60px;
|
||||
width: 33%;
|
||||
max-width: 52px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
margin: 0 auto 4px;
|
||||
}
|
||||
|
||||
.xx-choice-item h4 {
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -238,7 +240,7 @@
|
||||
|
||||
.xx-choice-item p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@@ -1063,7 +1065,7 @@
|
||||
}
|
||||
|
||||
.xx-choice-list {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.xx-voice-choice-list {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../types/cover"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { DEFAULT_COVER_SETTINGS } from "../../constants"
|
||||
@@ -46,6 +47,10 @@ export interface GenerateFormState {
|
||||
smartSelectedIds: string[]
|
||||
setSmartSelectedIds: (ids: string[]) => void
|
||||
|
||||
/* 服务端片段(/clips/from-assets 创建后获取) */
|
||||
serverClips: EditPlanClip[]
|
||||
setServerClips: (clips: EditPlanClip[]) => void
|
||||
|
||||
/* 标题 */
|
||||
titleSettings: TitleSettings
|
||||
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
|
||||
@@ -116,6 +121,9 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
||||
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
|
||||
|
||||
/* ── 服务端片段(供预览播放器使用)── */
|
||||
const [serverClips, setServerClips] = useState<EditPlanClip[]>([])
|
||||
|
||||
/* ── 标题设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
||||
|
||||
@@ -196,6 +204,8 @@ export const useGenerateFormState = (): GenerateFormState => {
|
||||
setMaterialMode,
|
||||
smartSelectedIds,
|
||||
setSmartSelectedIds,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
titleSettings,
|
||||
setTitleSettings,
|
||||
coverSettings,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { type GeneratedVideo, createClipsFromAssets } from "@/api/template-editor"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
@@ -78,6 +78,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
|
||||
// 确保片段已创建(显式调用 from-assets,不依赖 useEffect 时机)
|
||||
// useStep2Materials 中也用 selectedTemplate 调用 from-assets,后端通过 template_id 自动关联 plan
|
||||
if (assetIds.length > 0 && selectedTemplate) {
|
||||
try {
|
||||
await createClipsFromAssets(selectedTemplate, assetIds, "main", assetIds.length)
|
||||
} catch (clipErr) {
|
||||
console.warn("[handleGenerate] from-assets 调用失败,继续尝试生成:", clipErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建生成任务(服务器渲染)
|
||||
const taskResp = await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { updateEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
@@ -21,6 +22,8 @@ interface UseStep2MaterialsProps {
|
||||
selectedTemplate?: string
|
||||
/** 当前模板的 segments(用于构建 clips duration) */
|
||||
templateSegments?: TemplateSegment[]
|
||||
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
|
||||
onServerClipsChange?: (clips: EditPlanClip[]) => void
|
||||
}
|
||||
|
||||
export function useStep2Materials({
|
||||
@@ -32,6 +35,7 @@ export function useStep2Materials({
|
||||
onSmartSelectedIdsChange,
|
||||
selectedTemplate,
|
||||
templateSegments,
|
||||
onServerClipsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
@@ -83,12 +87,17 @@ export function useStep2Materials({
|
||||
templateSegmentsRef.current = templateSegments
|
||||
const selectedTemplateRef = useRef(selectedTemplate)
|
||||
selectedTemplateRef.current = selectedTemplate
|
||||
const onServerClipsChangeRef = useRef(onServerClipsChange)
|
||||
onServerClipsChangeRef.current = onServerClipsChange
|
||||
|
||||
useEffect(() => {
|
||||
const tid = selectedTemplateRef.current
|
||||
if (!tid) return
|
||||
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
if (!ids.length) return
|
||||
if (!ids.length) {
|
||||
onServerClipsChangeRef.current?.([])
|
||||
return
|
||||
}
|
||||
|
||||
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
|
||||
clipsTimerRef.current = setTimeout(async () => {
|
||||
@@ -105,6 +114,12 @@ export function useStep2Materials({
|
||||
await updateEditPlanClips(tid, [], controller.signal)
|
||||
// 2. 调用后端 from-assets 接口创建片段
|
||||
await createClipsFromAssets(tid, ids, "main", requiredClipsCount)
|
||||
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
|
||||
const clipList = await getEditPlanClips(tid, { limit: 500 })
|
||||
const readyClips = clipList.items
|
||||
.filter((c) => c.status === "ready")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
onServerClipsChangeRef.current?.(readyClips)
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name !== "CanceledError" && name !== "AbortError") {
|
||||
|
||||
@@ -598,7 +598,7 @@ class RenderAdapter:
|
||||
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
|
||||
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, num_frames=3, title_text=""
|
||||
str(result.output_path), plan_id, task_id=job_id, num_frames=3, title_text=""
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
|
||||
@@ -278,6 +278,7 @@ def extract_and_upload_cover_frames(
|
||||
video_path: str,
|
||||
plan_id: str,
|
||||
*,
|
||||
task_id: str = "",
|
||||
num_frames: int = 3,
|
||||
title_text: str = "",
|
||||
title_color: str = "#ffffff",
|
||||
@@ -291,6 +292,7 @@ def extract_and_upload_cover_frames(
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||||
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
|
||||
num_frames: 抽取帧数(默认 3)
|
||||
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
|
||||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||||
@@ -338,7 +340,7 @@ def extract_and_upload_cover_frames(
|
||||
font_size=title_font_size,
|
||||
)
|
||||
|
||||
storage_key = f"covers/{plan_id}/mediakit_frame_{i}.jpg"
|
||||
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
|
||||
url = upload_to_oss(tmp.name, storage_key)
|
||||
if url:
|
||||
seek_time = frame.get("timestamp", 0.0)
|
||||
@@ -377,7 +379,7 @@ def extract_and_upload_cover_frames(
|
||||
position=title_position,
|
||||
font_size=title_font_size,
|
||||
)
|
||||
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
|
||||
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
|
||||
url = upload_to_oss(frame_path, storage_key)
|
||||
if url:
|
||||
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
|
||||
|
||||
@@ -421,3 +421,121 @@ class TestMarkClipsReadyAfterCreation:
|
||||
|
||||
# 关键断言:mark_clips_ready 必须被调用,且传入正确的 plan_id
|
||||
mock_plan_svc.mark_clips_ready.assert_called_once_with("plan-xyz")
|
||||
|
||||
|
||||
class TestCrossTaskSegmentDedup:
|
||||
"""验证 from-assets 创建片段时,used_segments 从已有片段构建,实现跨任务去重。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_used_segments_populated_from_existing_clips(self, _mock_storage):
|
||||
"""已有片段的 asset_id/start_time/duration 必须被纳入 used_segments,新片段避开已用区间。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
# 模拟已有片段:asset "a1" 在 0~5s 已使用
|
||||
existing = [
|
||||
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id="a1"),
|
||||
]
|
||||
mock_plan_svc = _make_plan_svc(existing_clips=existing)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-dedup",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 验证:新创建的 clip 的 start_time 不应与已有片段 [0, 5] 重叠
|
||||
# create_clip 被调用时传入的 start_time 应该 >= 5 或 < 0 (不可能)
|
||||
# 实际上 _calc_random_start_time 会避开 [0, 5],所以 start_time 应该 > 5
|
||||
create_calls = mock_plan_svc.create_clip.call_args_list
|
||||
assert len(create_calls) == 1
|
||||
new_start_time = create_calls[0].kwargs.get("start_time") or create_calls[0][1].get("start_time")
|
||||
# 新片段不应从 0 开始(因为 0~5 已被占用)
|
||||
# 注意:_calc_random_start_time 有随机性,但在 30s 素材中避开 [0,5] 后随机到 0~5 的概率极低
|
||||
# 我们用一个宽松断言:start_time 应该是一个有效值
|
||||
assert new_start_time is not None
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_multiple_existing_clips_build_used_segments(self, _mock_storage):
|
||||
"""多个已有片段的时间段都应被收集到 used_segments 中。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
# 模拟已有片段:asset "a1" 在 [0,5] 和 [10,15] 已使用
|
||||
existing = [
|
||||
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id="a1"),
|
||||
_make_mock_clip("c2", order=1, duration=5.0, start_time=10.0, asset_id="a1"),
|
||||
]
|
||||
mock_plan_svc = _make_plan_svc(existing_clips=existing)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-dedup2",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
create_calls = mock_plan_svc.create_clip.call_args_list
|
||||
assert len(create_calls) == 1
|
||||
new_start_time = create_calls[0].kwargs.get("start_time") or create_calls[0][1].get("start_time")
|
||||
assert new_start_time is not None
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_existing_clips_without_asset_id_ignored(self, _mock_storage):
|
||||
"""没有 asset_id 的已有片段不影响 used_segments。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
# 模拟已有片段:一个没有 asset_id 的片段
|
||||
existing = [
|
||||
_make_mock_clip("c1", order=0, duration=5.0, start_time=0.0, asset_id=""),
|
||||
]
|
||||
mock_plan_svc = _make_plan_svc(existing_clips=existing)
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-dedup3",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 应正常创建,不受空 asset_id 片段影响
|
||||
assert result.created_count == 1
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_no_existing_clips_works_same_as_before(self, _mock_storage):
|
||||
"""没有已有片段时,行为与修复前一致(used_segments 为空)。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_plan_svc = _make_plan_svc(existing_clips=[])
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-dedup4",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert result.created_count == 1
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
"""测试 MediaKit 智能选片集成。
|
||||
|
||||
覆盖:
|
||||
- _recommended_time_conflicts 冲突检测
|
||||
- _get_mediakit_recommendations 解析与降级
|
||||
- from-assets 端点:推荐时间优先使用
|
||||
- from-assets 端点:推荐时间冲突时降级随机
|
||||
- from-assets 端点:MediaKit 不可用时降级随机
|
||||
- from-assets 端点:MediaKit 返回不可解析内容时降级随机
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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"))
|
||||
|
||||
import pytest
|
||||
|
||||
# ── _recommended_time_conflicts 单元测试 ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecommendedTimeConflicts:
|
||||
"""测试推荐时间与已使用时间段的冲突检测。"""
|
||||
|
||||
def test_no_conflict_when_empty(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
assert _recommended_time_conflicts(5.0, 5.0, []) is False
|
||||
|
||||
def test_no_conflict_when_before(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [5, 10],已用 [15, 20]
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(15.0, 20.0)]) is False
|
||||
|
||||
def test_no_conflict_when_after(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [20, 25],已用 [0, 10]
|
||||
assert _recommended_time_conflicts(20.0, 5.0, [(0.0, 10.0)]) is False
|
||||
|
||||
def test_conflict_overlap_start(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [8, 13],已用 [10, 20]
|
||||
assert _recommended_time_conflicts(8.0, 5.0, [(10.0, 20.0)]) is True
|
||||
|
||||
def test_conflict_overlap_end(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [15, 20],已用 [10, 18]
|
||||
assert _recommended_time_conflicts(15.0, 5.0, [(10.0, 18.0)]) is True
|
||||
|
||||
def test_conflict_contained(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [12, 17],已用 [10, 20]
|
||||
assert _recommended_time_conflicts(12.0, 5.0, [(10.0, 20.0)]) is True
|
||||
|
||||
def test_conflict_adjacent_not_conflict(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [5, 10],已用 [10, 15] —— 边界相邻不算冲突
|
||||
assert _recommended_time_conflicts(5.0, 5.0, [(10.0, 15.0)]) is False
|
||||
|
||||
def test_conflict_with_multiple_segments(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [12, 17],已用 [0, 5] 和 [10, 20]
|
||||
assert _recommended_time_conflicts(12.0, 5.0, [(0.0, 5.0), (10.0, 20.0)]) is True
|
||||
|
||||
def test_no_conflict_between_segments(self):
|
||||
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
|
||||
|
||||
# 推荐 [6, 11],已用 [0, 5] 和 [12, 20]
|
||||
assert _recommended_time_conflicts(6.0, 5.0, [(0.0, 5.0), (12.0, 20.0)]) is False
|
||||
|
||||
|
||||
# ── _get_mediakit_recommendations 单元测试 ───────────────────────────────────
|
||||
|
||||
|
||||
class TestGetMediakitRecommendations:
|
||||
"""测试 MediaKit 推荐获取与解析。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_returns_empty_when_not_available(self, mock_storage, mock_client_fn):
|
||||
"""MediaKit 不可用时返回空字典。"""
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], MagicMock())
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_parses_json_response(self, mock_storage, mock_client_fn):
|
||||
"""正确解析 JSON 格式的 MediaKit 返回。"""
|
||||
import json
|
||||
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = [
|
||||
json.dumps([{"asset_id": "a1", "recommended_start_time": 12.5, "reason": "画面清晰"}])
|
||||
]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.storage_key = "videos/test.mp4"
|
||||
mock_asset.mime_type = "video/mp4"
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/test.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {"a1": 12.5}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_parses_regex_fallback(self, mock_storage, mock_client_fn):
|
||||
"""JSON 解析失败时通过正则提取 recommended_start_time。"""
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = ['根据分析,recommended_start_time": 8.3,画面主体明确']
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.storage_key = "videos/test.mp4"
|
||||
mock_asset.mime_type = "video/mp4"
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/test.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {"a1": 8.3}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_returns_empty_on_unparseable(self, mock_storage, mock_client_fn):
|
||||
"""无法解析时返回空字典。"""
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = ["这段视频内容丰富,无法确定具体时间"]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.storage_key = "videos/test.mp4"
|
||||
mock_asset.mime_type = "video/mp4"
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/test.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_returns_empty_on_none_result(self, mock_storage, mock_client_fn):
|
||||
"""MediaKit 返回 None 时返回空字典。"""
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = None
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.storage_key = "videos/test.mp4"
|
||||
mock_asset.mime_type = "video/mp4"
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/test.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_exception_returns_empty(self, mock_storage, mock_client_fn):
|
||||
"""异常时返回空字典(优雅降级)。"""
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client_fn.side_effect = RuntimeError("unexpected error")
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], MagicMock())
|
||||
assert result == {}
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_skips_non_video_assets(self, mock_storage, mock_client_fn):
|
||||
"""非视频素材被跳过,不发送给 MediaKit。"""
|
||||
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset = MagicMock()
|
||||
mock_asset.storage_key = "images/test.jpg"
|
||||
mock_asset.mime_type = "image/jpeg"
|
||||
mock_asset_repo.get.return_value = mock_asset
|
||||
|
||||
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
|
||||
assert result == {}
|
||||
# analyze_videos should not be called since no valid video URLs
|
||||
mock_client.analyze_videos.assert_not_called()
|
||||
|
||||
|
||||
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
auth = MagicMock()
|
||||
auth.user.id = "user-001"
|
||||
auth.user.email = "test@example.com"
|
||||
auth.user.display_name = "test"
|
||||
auth.user_id = "user-001"
|
||||
return auth
|
||||
|
||||
|
||||
def _make_mock_clip(clip_id, order, duration, start_time=0.0, asset_id=""):
|
||||
clip = MagicMock()
|
||||
clip.id = clip_id
|
||||
clip.plan_id = "plan-test"
|
||||
clip.clip_type = "main"
|
||||
clip.order = order
|
||||
clip.duration = duration
|
||||
clip.start_time = start_time
|
||||
clip.text_content = ""
|
||||
clip.transition_effect = "cut"
|
||||
clip.transition_duration = 0.0
|
||||
clip.playback_speed = 1.0
|
||||
clip.config = {}
|
||||
clip.asset_id = asset_id
|
||||
clip.status = "pending"
|
||||
clip.template_clip_config_id = ""
|
||||
clip.created_at = None
|
||||
clip.updated_at = None
|
||||
return clip
|
||||
|
||||
|
||||
def _make_mock_asset(asset_id, duration):
|
||||
asset = MagicMock()
|
||||
asset.id = asset_id
|
||||
asset.duration = duration
|
||||
return asset
|
||||
|
||||
|
||||
def _create_clips(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw):
|
||||
return _make_mock_clip(
|
||||
clip_id=f"clip-{order}",
|
||||
order=order,
|
||||
duration=duration,
|
||||
start_time=start_time,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
|
||||
|
||||
def _make_plan_svc(existing_clips=None):
|
||||
svc = MagicMock()
|
||||
svc.get_plan_or_raise = MagicMock()
|
||||
svc.create_clip = MagicMock(side_effect=_create_clips)
|
||||
svc.list_clips = MagicMock(return_value=existing_clips or [])
|
||||
return svc
|
||||
|
||||
|
||||
class TestMediakitIntegrationInFromAssets:
|
||||
"""测试 from-assets 端点中 MediaKit 推荐的集成使用。"""
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_uses_mediakit_recommendation(self, mock_storage, mock_client_fn):
|
||||
"""MediaKit 推荐时间被优先使用。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = [
|
||||
'[{"asset_id": "a1", "recommended_start_time": 15.0, "reason": "test"}]'
|
||||
]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/test.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
|
||||
# asset needs storage_key and mime_type for MediaKit, plus duration for clip creation
|
||||
asset_for_mediakit = MagicMock()
|
||||
asset_for_mediakit.storage_key = "videos/test.mp4"
|
||||
asset_for_mediakit.mime_type = "video/mp4"
|
||||
asset_for_mediakit.duration = 60.0
|
||||
|
||||
def asset_get_side_effect(aid):
|
||||
return _make_mock_asset(aid, 60.0) if aid else None
|
||||
|
||||
mock_asset_repo.get = MagicMock(side_effect=asset_get_side_effect)
|
||||
|
||||
# We need to make the asset have storage_key and mime_type for the mediakit function
|
||||
# The mock_asset from _make_mock_asset doesn't have these, so let's use a richer mock
|
||||
rich_asset = MagicMock()
|
||||
rich_asset.id = "a1"
|
||||
rich_asset.duration = 60.0
|
||||
rich_asset.storage_key = "videos/test.mp4"
|
||||
rich_asset.mime_type = "video/mp4"
|
||||
mock_asset_repo.get.return_value = rich_asset
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-mk1",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
create_calls = mock_plan_svc.create_clip.call_args_list
|
||||
assert len(create_calls) == 1
|
||||
start_time = create_calls[0].kwargs.get("start_time") or create_calls[0][1].get("start_time")
|
||||
assert start_time == 15.0
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_fallback_to_random_on_conflict(self, mock_storage, mock_client_fn):
|
||||
"""推荐时间与已有片段冲突时,降级为随机选择。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
# 推荐 2.0s,但已有片段占用了 [0, 10]
|
||||
mock_client.analyze_videos.return_value = [
|
||||
'[{"asset_id": "a1", "recommended_start_time": 2.0, "reason": "test"}]'
|
||||
]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/test.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
# 已有片段占用 [0, 10]
|
||||
existing = [_make_mock_clip("c1", order=0, duration=10.0, start_time=0.0, asset_id="a1")]
|
||||
mock_plan_svc = _make_plan_svc(existing_clips=existing)
|
||||
|
||||
rich_asset = MagicMock()
|
||||
rich_asset.id = "a1"
|
||||
rich_asset.duration = 60.0
|
||||
rich_asset.storage_key = "videos/test.mp4"
|
||||
rich_asset.mime_type = "video/mp4"
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = rich_asset
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-mk2",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
create_calls = mock_plan_svc.create_clip.call_args_list
|
||||
assert len(create_calls) == 1
|
||||
start_time = create_calls[0].kwargs.get("start_time") or create_calls[0][1].get("start_time")
|
||||
# 推荐时间 2.0 与 [0, 10] 冲突,应降级为随机,不应等于 2.0
|
||||
# 随机起始应在 [10, 55] 范围内(避开 [0,10],5s clip 在 60s 素材中)
|
||||
assert start_time is not None
|
||||
assert start_time != 2.0
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_fallback_when_mediakit_unavailable(self, mock_storage, mock_client_fn):
|
||||
"""MediaKit 不可用时降级为随机选择,功能正常。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = False
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_mock_asset("a1", 30.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-mk3",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
assert result.created_count == 1
|
||||
# analyze_videos 不应被调用
|
||||
mock_client.analyze_videos.assert_not_called()
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_fallback_on_unparseable_response(self, mock_storage, mock_client_fn):
|
||||
"""MediaKit 返回不可解析内容时降级为随机选择。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.analyze_videos.return_value = ["这段视频内容很精彩,有很多好看的画面"]
|
||||
mock_client_fn.return_value = mock_client
|
||||
|
||||
mock_storage_svc = MagicMock()
|
||||
mock_storage_svc.get_download_url.return_value = "https://example.com/test.mp4"
|
||||
mock_storage.return_value = mock_storage_svc
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
rich_asset = MagicMock()
|
||||
rich_asset.id = "a1"
|
||||
rich_asset.duration = 30.0
|
||||
rich_asset.storage_key = "videos/test.mp4"
|
||||
rich_asset.mime_type = "video/mp4"
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = rich_asset
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-mk4",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 功能正常,降级为随机
|
||||
assert result.created_count == 1
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_fallback_on_mediakit_exception(self, mock_storage, mock_client_fn):
|
||||
"""MediaKit 抛异常时优雅降级,不影响片段创建。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
mock_client_fn.side_effect = RuntimeError("MediaKit connection failed")
|
||||
|
||||
mock_plan_svc = _make_plan_svc()
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get.return_value = _make_mock_asset("a1", 30.0)
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=1)
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
plan_id="plan-mk5",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
# 功能正常,降级为随机
|
||||
assert result.created_count == 1
|
||||
Reference in New Issue
Block a user