Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 86afbb3eaf fix(#1341): 修复 EditorDrawers totalDuration 重复定义
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 41s
AI Code Review / AI Code Review (pull_request) Successful in 1m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m26s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m34s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m30s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m59s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m44s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 9s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 52s
2026-08-12 16:07:58 +08:00
29 changed files with 1363 additions and 151 deletions
@@ -52,13 +52,11 @@ def editor_generate_cover(
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
# 第一步:从 plan.config 读取
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
if not rendered_storage_key:
generation_task_id = (plan.config or {}).get("generation_task_id", "")
logger.info("[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id)
if generation_task_id:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
@@ -70,12 +68,7 @@ def editor_generate_cover(
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
logger.info(
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
plan_id,
generation_task_id,
rendered_storage_key[:80],
)
logger.info(
"封面生成: 通过 generation_task_id 找到视频: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
)
@@ -90,7 +83,6 @@ def editor_generate_cover(
if not rendered_storage_key:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
preview_tasks = gen_task_repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
template_id=template_id,
@@ -118,7 +110,6 @@ def editor_generate_cover(
# 仍然找不到才报 400
if not rendered_storage_key:
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
raise HTTPException(
status_code=400,
detail="请先生成预览视频,再生成封面",
@@ -151,7 +142,6 @@ def editor_generate_cover(
from packages.shared.ai_service import run_generate_cover
try:
logger.info("[封面生成] 开始调用 AI 封面生成服务: plan_id=%s", plan_id)
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
+278
View File
@@ -0,0 +1,278 @@
"""封面管理服务.
提供封面配置管理和从视频抽帧生成封面的能力。
抽帧使用 FFmpeg,上传使用共享存储服务。
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
COVER_STORAGE_PREFIX = "covers"
class CoverService:
"""封面管理服务."""
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
self._storage = storage_service
self._asset_repo = asset_repository
# ── 配置读写 ──────────────────────────────────────────────────────────
@staticmethod
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
"""从 plan.config 中提取封面配置.
Args:
plan_config: 剪辑计划的 config 字段
Returns:
封面配置 dict
"""
cover = plan_config.get("cover", {})
if not isinstance(cover, dict):
cover = {}
# 确保默认字段存在
return {
"type": cover.get("type", "ai_frame"),
"image_url": cover.get("image_url", ""),
"frame_time": cover.get("frame_time"),
}
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
def extract_cover_from_clip(
self,
plan_id: str,
asset_id: str,
frame_time: float = 1.0,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""从指定素材的指定时间点抽取一帧作为封面.
Args:
plan_id: 剪辑计划 ID(用于生成存储路径)
asset_id: 素材 ID
frame_time: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict,包含 type / image_url / frame_time
Raises:
ValueError: 素材不存在或不是视频
RuntimeError: 抽帧或上传失败
"""
# 1. 获取素材
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
if not asset:
raise ValueError(f"素材不存在: {asset_id}")
storage_key = getattr(asset, "storage_key", "")
if not storage_key:
raise ValueError(f"素材没有文件: {asset_id}")
mime_type = getattr(asset, "mime_type", "")
if mime_type and not mime_type.startswith("video"):
raise ValueError(f"素材不是视频类型: {mime_type}")
# 2. 下载视频到临时目录
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
tmp_path = Path(tmp_dir)
video_path = tmp_path / f"source_{asset_id[:8]}"
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
try:
self._storage.download_file(storage_key, str(video_path))
except Exception as e:
raise RuntimeError(f"下载素材失败: {e}") from e
if not video_path.exists() or video_path.stat().st_size == 0:
raise RuntimeError("下载的素材文件为空")
# 3. FFmpeg 抽帧
output_path = tmp_path / "cover.jpg"
self._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=frame_time,
width=width,
height=height,
quality=quality,
)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError("封面抽帧失败")
# 4. 上传到 OSS
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
logger.info("上传封面到存储: key=%s", cover_key)
try:
self._storage.upload_file(
file_or_path=str(output_path),
storage_key=cover_key,
content_type="image/jpeg",
)
except Exception as e:
raise RuntimeError(f"上传封面失败: {e}") from e
# 5. 获取访问 URL
try:
image_url = self._storage.get_url(cover_key)
except Exception:
image_url = cover_key # 降级为 storage_key
logger.info(
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
plan_id,
asset_id,
frame_time,
output_path.stat().st_size if output_path.exists() else 0,
)
return {
"type": "manual",
"image_url": image_url,
"frame_time": frame_time,
}
def generate_smart_cover(
self,
plan_id: str,
asset_id: str,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
Args:
plan_id: 剪辑计划 ID
asset_id: 素材 ID
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict
"""
# 简单实现:取视频 1/3 处的帧作为智能封面
# 更复杂的多帧选清晰帧可以后续优化
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
result = self.extract_cover_from_clip(
plan_id=plan_id,
asset_id=asset_id,
frame_time=frame_time,
width=width,
height=height,
quality=quality,
)
result["type"] = "ai_frame"
return result
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _extract_frame(
video_path: Path,
output_path: Path,
*,
time_sec: float,
width: int,
height: int,
quality: int,
) -> None:
"""使用 FFmpeg 从视频中抽取一帧.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
"""
import subprocess
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height},format=yuvj420p"
command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
# 尝试不使用 scale+crop 的简化命令
simple_command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-q:v",
str(quality),
"-pix_fmt",
"yuvj420p",
"-f",
"mjpeg",
str(output_path),
]
result2 = subprocess.run(
simple_command,
capture_output=True,
text=True,
timeout=60,
)
if result2.returncode != 0:
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
except subprocess.TimeoutExpired as e:
raise RuntimeError("FFmpeg 抽帧超时") from e
except FileNotFoundError as e:
raise RuntimeError("FFmpeg 不可用") from e
+1 -1
View File
@@ -7,7 +7,7 @@ export const confirmGeneration = async (
params: ConfirmGenerationRequest,
): Promise<ConfirmGenerationResponse> => {
const response = await apiClient.post<ConfirmGenerationResponse>(
`/generation/tasks/${taskId}/confirm`,
`/tasks/${taskId}/confirm`,
params,
)
return response.data
@@ -118,10 +118,6 @@ export interface EditPlanConfig {
generate_count?: number
/** 素材模式 */
material_mode?: string
/** 预览视频 URL(封面生成用) */
rendered_storage_key?: string
/** 生成任务 ID */
generation_task_id?: string
}
/* ── 模板草稿主体 ── */
@@ -74,6 +74,8 @@ const EditingPlanner: React.FC = () => {
setChromaKeySettings,
stickerSettings,
setStickerSettings,
coverConfig,
setCoverConfig,
} = useGlobalSettings()
/* ── 右侧栏 Tab ── */
@@ -117,6 +119,7 @@ const EditingPlanner: React.FC = () => {
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
clips,
totalDuration,
titleConfig,
@@ -128,6 +131,7 @@ const EditingPlanner: React.FC = () => {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
})
/* ──────────── 渲染 ──────────── */
@@ -177,6 +181,7 @@ const EditingPlanner: React.FC = () => {
selectedClipId={clipOps.selectedClipId}
isPlaying={playback.isPlaying}
titleConfig={titleConfig}
coverConfig={coverConfig}
subtitleSettings={{
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
@@ -238,6 +243,7 @@ const EditingPlanner: React.FC = () => {
onOpenFilterDrawer={() => drawers.setFilterDrawerOpen(true)}
onOpenGreenScreenDrawer={() => drawers.setChromaKeyDrawerOpen(true)}
onOpenStickerDrawer={() => drawers.setStickerDrawerOpen(true)}
onOpenCoverDrawer={() => drawers.setCoverDrawerOpen(true)}
clips={clips}
selectedClipId={clipOps.selectedClipId}
onClipSelect={clipOps.handleClipSelect}
@@ -330,6 +336,11 @@ const EditingPlanner: React.FC = () => {
stickerSettings={stickerSettings}
onStickerChange={setStickerSettings}
onCloseStickerDrawer={() => drawers.setStickerDrawerOpen(false)}
coverDrawerOpen={drawers.coverDrawerOpen}
onCloseCoverDrawer={() => drawers.setCoverDrawerOpen(false)}
coverConfig={coverConfig}
setCoverConfig={setCoverConfig}
templateId={urlTemplateId}
/>
</div>
)
@@ -35,6 +35,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
onOpenCoverDrawer,
}) => {
const { previewingId, handlePreviewVoice, stopPreview } = useVoicePreview()
@@ -140,6 +141,21 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
)}
</div>
{/* ═══ 封面设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenCoverDrawer && (
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片段详情(选中时显示) ═══ */}
{selectedClip && (
<ClipDetailSection
@@ -0,0 +1,5 @@
/**
* 封面选择器入口(向后兼容)
* 实际实现位于 ./cover-selector/ 目录
*/
export { default } from "./cover-selector"
@@ -10,6 +10,7 @@ import PipConfigPanel from "./PipConfigPanel"
import FilterPanel from "./FilterPanel"
import GreenScreenPanel from "./GreenScreenPanel"
import StickerPanel from "./StickerPanel"
import CoverSelector from "./CoverSelector"
import type {
ClipData,
TransitionConfig,
@@ -23,6 +24,7 @@ import type {
StickerConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { CoverConfig } from "../types/cover"
import type { BgmMixConfig } from "@/api/bgm"
import { DEFAULT_TRANSITION, DEFAULT_SPEED, DEFAULT_TTS_CONFIG } from "../types"
@@ -85,6 +87,12 @@ interface EditorDrawersProps {
stickerSettings: StickerConfig
onStickerChange: (config: StickerConfig) => void
onCloseStickerDrawer: () => void
// 封面
coverDrawerOpen: boolean
onCloseCoverDrawer: () => void
coverConfig: CoverConfig
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
templateId: string
}
const EditorDrawers: React.FC<EditorDrawersProps> = ({
@@ -135,6 +143,11 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
stickerSettings,
onStickerChange,
onCloseStickerDrawer,
coverDrawerOpen,
onCloseCoverDrawer,
coverConfig,
setCoverConfig,
templateId,
}) => {
const transitionConfig = transitionTargetClipId
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ?? DEFAULT_TRANSITION)
@@ -240,6 +253,15 @@ const EditorDrawers: React.FC<EditorDrawersProps> = ({
totalDuration={totalDuration}
/>
{/* 封面选择器 Drawer */}
<CoverSelector
open={coverDrawerOpen}
onClose={onCloseCoverDrawer}
config={coverConfig}
onChange={setCoverConfig}
totalDuration={totalDuration}
templateId={templateId}
/>
</>
)
}
@@ -1,10 +1,12 @@
/**
* 预览区 — V8 原型 1:1 还原
* 手机模型预览
* 手机模型预览 + 封面预览 并排
* 封面为只读展示(从模板/计划继承)
*/
import React from "react"
import type { ClipData, ClipType } from "../types"
import type { TitleConfig } from "@/api/template-editor"
import type { CoverConfig } from "../types"
interface SubtitleSettings {
enabled: boolean
@@ -19,6 +21,7 @@ interface PreviewPlayerProps {
selectedClipId: string | null
isPlaying: boolean
titleConfig?: TitleConfig
coverConfig?: CoverConfig
subtitleSettings?: SubtitleSettings
onClipSelect: (clipId: string) => void
onPlayPause: () => void
@@ -34,11 +37,18 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
pip: "混剪",
}
const COVER_MODE_LABELS: Record<string, string> = {
auto: "智能封面",
frame: "抽帧封面",
upload: "上传封面",
}
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
clips,
selectedClipId,
isPlaying,
titleConfig,
coverConfig,
subtitleSettings,
onPlayPause,
}) => {
@@ -122,6 +132,26 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
)}
</div>
</div>
{/* 封面预览(只读) */}
<div className="ep-cover-preview">
<div className="ep-cover-image">
{coverConfig?.thumbnail_url || coverConfig?.upload_url ? (
<img
src={coverConfig.thumbnail_url || coverConfig.upload_url}
alt="封面预览"
className="ep-cover-img"
/>
) : displayClip ? (
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
) : (
<span></span>
)}
</div>
<div className="ep-cover-label">
{coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
</div>
</div>
</div>
)
}
@@ -35,6 +35,7 @@ interface RightPanelProps {
onOpenFilterDrawer: () => void
onOpenGreenScreenDrawer: () => void
onOpenStickerDrawer: () => void
onOpenCoverDrawer: () => void
// 片段 tab
clips: ClipData[]
selectedClipId: string | null
@@ -72,6 +73,7 @@ const RightPanel: React.FC<RightPanelProps> = ({
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
onOpenCoverDrawer,
clips,
selectedClipId,
onClipSelect,
@@ -142,6 +144,7 @@ const RightPanel: React.FC<RightPanelProps> = ({
onOpenFilterDrawer={onOpenFilterDrawer}
onOpenGreenScreenDrawer={onOpenGreenScreenDrawer}
onOpenStickerDrawer={onOpenStickerDrawer}
onOpenCoverDrawer={onOpenCoverDrawer}
/>
)
})()}
@@ -0,0 +1,143 @@
import React from "react"
import type { CoverConfig } from "../../types"
interface CoverAutoModeProps {
config: CoverConfig
formatTime: (s: number) => string
onUseAiSuggestion: () => void
}
/** 智能封面模式面板 */
export const CoverAutoMode: React.FC<CoverAutoModeProps> = ({
config,
formatTime,
onUseAiSuggestion,
}) => (
<div className="cover-auto-section">
<div className="cover-auto-desc">AI </div>
{config.ai_suggested_time !== null ? (
<div className="cover-auto-suggestion">
<div className="cover-auto-badge">AI </div>
<div className="cover-auto-time">{formatTime(config.ai_suggested_time)}</div>
<button className="cover-auto-use-btn" onClick={onUseAiSuggestion}>
使
</button>
</div>
) : (
<div className="cover-auto-pending">
<div className="cover-auto-spinner" />
<span>AI ...</span>
</div>
)}
</div>
)
interface CoverFrameModeProps {
config: CoverConfig
totalDuration: number
formatTime: (s: number) => string
onFrameTimeChange: (time: number) => void
}
/** 抽帧选封面模式面板 */
export const CoverFrameMode: React.FC<CoverFrameModeProps> = ({
config,
totalDuration,
formatTime,
onFrameTimeChange,
}) => (
<div className="cover-frame-section">
<div className="cover-frame-preview">
<div className="cover-frame-placeholder">
<span className="cover-frame-icon">🎞</span>
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
</div>
</div>
<div className="cover-frame-timeline">
<div className="cover-frame-slider-header">
<span className="cover-frame-slider-label"></span>
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
</div>
<input
type="range"
className="cover-frame-slider"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={config.frame_time}
onChange={(e) => onFrameTimeChange(Number(e.target.value))}
/>
<div className="cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="cover-frame-quick">
<span className="cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button key={ratio} className="cover-quick-btn" onClick={() => onFrameTimeChange(t)}>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)
interface CoverUploadModeProps {
config: CoverConfig
isDragging: boolean
fileInputRef: React.RefObject<HTMLInputElement>
onDragOver: (e: React.DragEvent) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent) => void
onAreaClick: () => void
onFileChange: (file: File) => void
}
/** 上传封面模式面板 */
export const CoverUploadMode: React.FC<CoverUploadModeProps> = ({
config,
isDragging,
fileInputRef,
onDragOver,
onDragLeave,
onDrop,
onAreaClick,
onFileChange,
}) => (
<div className="cover-upload-section">
<div
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onAreaClick}
>
{config.upload_url ? (
<div className="cover-upload-preview">
<img src={config.upload_url} alt="封面预览" />
<div className="cover-upload-overlay"></div>
</div>
) : (
<div className="cover-upload-placeholder">
<span className="cover-upload-icon">📤</span>
<span className="cover-upload-text"></span>
<span className="cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) onFileChange(file)
}}
/>
</div>
</div>
)
@@ -0,0 +1,195 @@
/**
* 封面选择器
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
*/
import React, { useState } from "react"
import { Drawer, Modal, Spin, message } from "antd"
import { generateCover } from "@/api/template-editor/aiFeatures"
import type { CoverConfig, CoverMode } from "../../types"
import { useCoverSelector, MODE_LABELS, MODE_ICONS } from "./useCoverSelector"
import { CoverAutoMode, CoverFrameMode, CoverUploadMode } from "./CoverModePanels"
interface CoverSelectorProps {
open: boolean
onClose: () => void
config: CoverConfig
onChange: (config: CoverConfig) => void
totalDuration: number
templateId: string
}
const CoverSelector: React.FC<CoverSelectorProps> = ({
open,
onClose,
config,
onChange,
totalDuration,
templateId,
}) => {
const {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
} = useCoverSelector({ config, onChange })
const [generating, setGenerating] = useState(false)
const handleGenerateCover = async () => {
if (!templateId) {
message.error("请先保存模板")
return
}
setGenerating(true)
try {
const res = await generateCover(templateId, {
asset_ids: [],
cover_type: "ai_frame",
})
const imageUrl = res.cover?.image_url || res.cover?.thumbnail_url || ""
if (imageUrl) {
update({ upload_url: imageUrl, thumbnail_url: imageUrl })
}
message.success("封面生成成功")
} catch (err: any) {
message.error("封面生成失败: " + (err?.message || "未知错误"))
} finally {
setGenerating(false)
}
}
return (
<Drawer
title="封面选择"
placement="right"
width={440}
open={open}
onClose={onClose}
className="cover-selector-drawer"
>
{/* 顶部开关 */}
<div className="cover-header">
<span className="cover-header-label"></span>
<label className="cover-switch">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => update({ enabled: e.target.checked })}
/>
<span className="cover-switch-slider" />
</label>
</div>
{/* 模式选择 */}
<div className="cover-mode-section">
<div className="cover-section-title"></div>
<div className="cover-mode-tabs">
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
<button
key={m}
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
onClick={() => handleModeChange(m)}
>
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
</button>
))}
</div>
</div>
{/* 模式内容区 */}
<div className="cover-mode-content">
{config.mode === "auto" && (
<CoverAutoMode
config={config}
formatTime={formatTime}
onUseAiSuggestion={handleUseAiSuggestion}
/>
)}
{config.mode === "frame" && (
<CoverFrameMode
config={config}
totalDuration={totalDuration}
formatTime={formatTime}
onFrameTimeChange={(t) => update({ frame_time: t })}
/>
)}
{config.mode === "upload" && (
<CoverUploadMode
config={config}
isDragging={isDragging}
fileInputRef={fileInputRef}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onAreaClick={() => fileInputRef.current?.click()}
onFileChange={handleFileUpload}
/>
)}
</div>
{/* 封面预览 */}
<div className="cover-preview-section">
<div className="cover-section-title"></div>
<div className="cover-preview-box">
{config.upload_url ? (
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
) : (
<div className="cover-preview-placeholder">
<span className="cover-preview-icon">🖼</span>
<span className="cover-preview-text">
{config.mode === "auto"
? "AI 智能选择"
: config.mode === "frame"
? `${formatTime(config.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="cover-preview-ratio">16:9</div>
</div>
</div>
{/* AI 生成封面按钮 */}
{config.enabled && config.mode === "auto" && (
<div className="cover-generate-section">
<button
className="cover-generate-btn"
onClick={handleGenerateCover}
disabled={generating}
>
{generating ? "生成中..." : "🤖 AI 生成封面"}
</button>
</div>
)}
{/* 底部 */}
<div className="cover-footer">
<button className="cover-reset-btn" onClick={handleReset}>
</button>
</div>
{/* 生成进度弹窗 */}
<Modal open={generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI ...</p>
</div>
</Modal>
</Drawer>
)
}
export default CoverSelector
@@ -0,0 +1,98 @@
import { useCallback, useRef, useState } from "react"
import type { CoverConfig, CoverMode } from "../../types"
import { DEFAULT_COVER_CONFIG } from "../../types"
/** 封面模式标签 */
export const MODE_LABELS: Record<CoverMode, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
/** 封面模式图标 */
export const MODE_ICONS: Record<CoverMode, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
interface UseCoverSelectorOptions {
config: CoverConfig
onChange: (config: CoverConfig) => void
}
/**
* 封面选择器 Hook
* 封装状态管理、文件上传、模式切换等逻辑
*/
export function useCoverSelector({ config, onChange }: UseCoverSelectorOptions) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const update = useCallback(
(partial: Partial<CoverConfig>) => {
onChange({ ...config, ...partial })
},
[config, onChange],
)
const handleReset = useCallback(() => {
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
}, [config.enabled, onChange])
const handleModeChange = useCallback(
(mode: CoverMode) => {
update({ mode })
},
[update],
)
const handleFileUpload = useCallback(
(file: File) => {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
const url = e.target?.result as string
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
}
reader.readAsDataURL(file)
},
[update],
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFileUpload(file)
},
[handleFileUpload],
)
const handleUseAiSuggestion = useCallback(() => {
if (config.ai_suggested_time !== null) {
update({ frame_time: config.ai_suggested_time, mode: "frame" })
}
}, [config.ai_suggested_time, update])
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
return {
fileInputRef,
isDragging,
setIsDragging,
update,
handleReset,
handleModeChange,
handleFileUpload,
handleDrop,
handleUseAiSuggestion,
formatTime,
}
}
@@ -6,6 +6,7 @@ import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { TitleConfig } from "@/api/template-editor"
import type { BgmMixConfig } from "@/api/bgm"
import type { TransitionEffect } from "@/api/template-editor"
import type { CoverConfig } from "../../types"
interface UsePlanLoadingOptions {
loadedPlanId: string | null
@@ -15,6 +16,7 @@ interface UsePlanLoadingOptions {
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
}
/**
@@ -29,6 +31,7 @@ export function usePlanLoading({
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
}: UsePlanLoadingOptions) {
useEffect(() => {
if (!loadedPlanId) return
@@ -74,6 +77,17 @@ export function usePlanLoading({
music_id: cfg.bgm_config!.music_id || "",
}))
}
if (cfg.cover_config) {
setCoverConfig((prev: CoverConfig) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
}))
}
/* 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 */
const backendClips = clipsRes?.items || []
@@ -138,5 +152,6 @@ export function usePlanLoading({
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
])
}
@@ -17,6 +17,7 @@ import type {
} from "../../types"
import type { SubtitleStyleConfig } from "../../types/subtitle"
import type { TitleConfig } from "@/api/template-editor"
import type { CoverConfig } from "../../types"
import type { BgmMixConfig } from "@/api/bgm"
interface UseTemplateSaveOptions {
@@ -32,6 +33,7 @@ interface UseTemplateSaveOptions {
filterSettings: FilterConfig
chromaKeySettings: ChromaKeyConfig
stickerSettings: StickerConfig
coverConfig: CoverConfig
loadedTemplateId: string | null
loadTemplates: () => Promise<void>
}
@@ -53,6 +55,7 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
} = options
@@ -129,6 +132,7 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
}
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
@@ -159,6 +163,7 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
])
@@ -17,6 +17,7 @@ export const useEditorDrawers = () => {
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false)
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false)
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false)
/* ── 目标片段 ID ── */
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
@@ -66,6 +67,8 @@ export const useEditorDrawers = () => {
setChromaKeyDrawerOpen,
stickerDrawerOpen,
setStickerDrawerOpen,
coverDrawerOpen,
setCoverDrawerOpen,
// 目标 ID
transitionTargetClipId,
speedTargetClipId,
@@ -11,6 +11,7 @@ import type {
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "../types"
import {
DEFAULT_WATERMARK,
@@ -19,6 +20,7 @@ import {
DEFAULT_FILTER_CONFIG,
DEFAULT_CHROMA_KEY_CONFIG,
DEFAULT_STICKER_CONFIG,
DEFAULT_COVER_CONFIG,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import { DEFAULT_SUBTITLE_STYLE } from "../types/subtitle"
@@ -45,6 +47,8 @@ export interface GlobalSettings {
setChromaKeySettings: (config: ChromaKeyConfig) => void
stickerSettings: StickerConfig
setStickerSettings: (config: StickerConfig) => void
coverConfig: CoverConfig
setCoverConfig: (config: CoverConfig | ((prev: CoverConfig) => CoverConfig)) => void
}
export const useGlobalSettings = (): GlobalSettings => {
@@ -88,6 +92,10 @@ export const useGlobalSettings = (): GlobalSettings => {
...DEFAULT_STICKER_CONFIG,
})
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
...DEFAULT_COVER_CONFIG,
})
return {
titleConfig,
setTitleConfig,
@@ -107,5 +115,7 @@ export const useGlobalSettings = (): GlobalSettings => {
setChromaKeySettings,
stickerSettings,
setStickerSettings,
coverConfig,
setCoverConfig,
}
}
@@ -9,6 +9,7 @@ import type {
FilterConfig,
ChromaKeyConfig,
StickerConfig,
CoverConfig,
} from "../types"
import type { SubtitleStyleConfig } from "../types/subtitle"
import type { BgmMixConfig } from "@/api/bgm"
@@ -28,6 +29,7 @@ interface UseTemplateManagementParams {
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
setCoverConfig: Dispatch<SetStateAction<CoverConfig>>
clips: ClipData[]
totalDuration: number
titleConfig: TitleConfig
@@ -39,6 +41,7 @@ interface UseTemplateManagementParams {
filterSettings: FilterConfig
chromaKeySettings: ChromaKeyConfig
stickerSettings: StickerConfig
coverConfig: CoverConfig
}
/**
@@ -56,6 +59,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
clips,
totalDuration,
titleConfig,
@@ -67,6 +71,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
} = params
const [currentMode, setCurrentMode] = useState<TemplateMode>("pip")
@@ -114,6 +119,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
filterSettings,
chromaKeySettings,
stickerSettings,
coverConfig,
loadedTemplateId,
loadTemplates,
})
@@ -140,6 +146,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
setCoverConfig,
})
/* ── 事件 ── */
@@ -67,4 +67,6 @@ export interface ClipPropertiesPanelProps {
onOpenGreenScreenDrawer?: () => void
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void
/** 打开封面选择器 Drawer */
onOpenCoverDrawer?: () => void
}
@@ -1,5 +1,4 @@
import React from "react"
import { Modal, Spin } from "antd"
import type { CoverConfig } from "../../editing-planner/types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import Button from "@/components/ui/Button"
@@ -19,7 +18,6 @@ interface Step6CoverSettingsProps {
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
const {
coverSettings,
generating,
generateAutoCover,
showCoverSettings,
setShowCoverSettings,
@@ -97,14 +95,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
template={editingTemplate}
onSave={handleSaveTemplate}
/>
{/* AI 生成封面进度弹窗 */}
<Modal open={generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI ...</p>
</div>
</Modal>
</div>
)
}
@@ -78,7 +78,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerateError(finalMsg)
message.error(finalMsg)
}
}, [props, clearTimer, startPolling])
}, [props, selectedTemplate, clearTimer, startPolling])
/* 重新生成(失败后重试) */
const retry = useCallback(() => {
@@ -4,7 +4,6 @@
*/
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
import { createPreview, getPreviewStatus } from "@/api/generation"
import { updateEditPlan } from "@/api/template-editor/editPlans"
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
import type { EditingTemplate } from "@/api/editing-planner"
import { safeExtractError } from "./generate-video/errorUtils"
@@ -212,102 +211,90 @@ export function useStep4Preview({
}, [clearPollTimer])
/** 轮询单个预览任务状态 */
const pollPreviewStatus = useCallback(
(index: number, taskId: string) => {
const poll = async () => {
// 竞态检查
const pollPreviewStatus = useCallback((index: number, taskId: string) => {
const poll = async () => {
// 竞态检查
if (taskIdsRef.current.get(index) !== taskId) return
// 超时检查
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
),
)
return
}
try {
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
if (taskIdsRef.current.get(index) !== taskId) return
// 超时检查
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
const status = data.status as ApiPreviewStatus
if (status === "completed") {
const result: PreviewResult = {
taskId: safeString(data.task_id, ""),
videoUrl: safeString(data.video_url, ""),
clipCount: safeNumber(data.clip_count),
transitionCount: safeNumber(data.transition_count),
materialUsage: safeNumber(data.material_usage),
duration: safeNumber(data.duration),
fileSize: safeNumber(data.file_size),
generateDuration: safeNumber(data.generate_duration),
progress: 100,
}
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
),
)
return
}
try {
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
if (taskIdsRef.current.get(index) !== taskId) return
const status = data.status as ApiPreviewStatus
if (status === "completed") {
const result: PreviewResult = {
taskId: safeString(data.task_id, ""),
videoUrl: safeString(data.video_url, ""),
clipCount: safeNumber(data.clip_count),
transitionCount: safeNumber(data.transition_count),
materialUsage: safeNumber(data.material_usage),
duration: safeNumber(data.duration),
fileSize: safeNumber(data.file_size),
generateDuration: safeNumber(data.generate_duration),
progress: 100,
}
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
),
)
// 保存预览视频 URL 到 plan config,供封面生成使用
if (result.videoUrl && selectedTemplate) {
updateEditPlan(selectedTemplate, {
config: { rendered_storage_key: result.videoUrl },
}).catch((err) => {
console.warn("[Step4] 保存预览视频URL到plan config失败:", err)
})
}
return
}
if (status === "failed") {
setItems((prev) =>
prev.map((it) =>
it.index === index
? {
...it,
status: "error",
error: safeString(data.error_message, "预览生成失败,请重试"),
}
: it,
),
)
return
}
if (status === "cancelled") {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
),
)
return
}
// pending / generating 状态继续轮询
const prog = safeNumber(data.progress)
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
if (status === "failed") {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
it.index === index
? {
...it,
status: "error",
error: safeString(data.error_message, "预览生成失败,请重试"),
}
: it,
),
)
const delay = status === "pending" ? 5000 : 2000
pollTimersRef.current.set(index, setTimeout(poll, delay))
} catch {
if (taskIdsRef.current.get(index) !== taskId) return
pollTimersRef.current.set(index, setTimeout(poll, 3000))
return
}
}
pollTimersRef.current.set(index, setTimeout(poll, 1000))
},
[selectedTemplate],
)
if (status === "cancelled") {
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
),
)
return
}
// pending / generating 状态继续轮询
const prog = safeNumber(data.progress)
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
setItems((prev) =>
prev.map((it) =>
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
),
)
const delay = status === "pending" ? 5000 : 2000
pollTimersRef.current.set(index, setTimeout(poll, delay))
} catch {
if (taskIdsRef.current.get(index) !== taskId) return
pollTimersRef.current.set(index, setTimeout(poll, 3000))
}
}
pollTimersRef.current.set(index, setTimeout(poll, 1000))
}, [])
/** 生成所有预览 */
const generatePreview = useCallback(async () => {
@@ -2,7 +2,7 @@
* Step 6 封面设置 Hook
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
*/
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { message } from "antd"
import type { CoverConfig, CoverTemplate } from "../../editing-planner/types"
import { generateCover } from "@/api/template-editor"
@@ -30,7 +30,7 @@ export function useStep6Cover({
assetIds = [],
selectedTemplate = "",
}: UseStep6CoverProps) {
const [generating, setGenerating] = useState(false)
const generatingRef = useRef(false)
// ── 封面设置弹窗状态 ──
const [showCoverSettings, setShowCoverSettings] = useState(false)
@@ -67,7 +67,7 @@ export function useStep6Cover({
/** 调用后端智能封面 API,生成封面并更新预览 */
const generateAutoCover = useCallback(async () => {
if (generating) {
if (generatingRef.current) {
message.warning("封面正在生成中,请稍候...")
return
}
@@ -82,17 +82,12 @@ export function useStep6Cover({
return
}
setGenerating(true)
// 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
const timeoutId = setTimeout(() => {
setGenerating(false)
}, 300000)
generatingRef.current = true
try {
const response = await generateCover(selectedTemplate, {
asset_ids: assetIds,
cover_type: "ai_frame",
})
clearTimeout(timeoutId)
const thumbnailUrl = response.cover?.image_url || ""
if (thumbnailUrl) {
onCoverSettingsChange({
@@ -105,35 +100,12 @@ export function useStep6Cover({
message.warning("封面生成未返回图片,请重试")
}
} catch (err) {
clearTimeout(timeoutId)
console.error("[Step6] 智能封面生成失败:", err)
// 提取详细错误信息
let errorMsg = "封面生成失败"
const e = err as {
response?: { data?: { detail?: string; message?: string }; status?: number }
request?: unknown
message?: string
}
if (e.response) {
// 后端返回错误
const detail = e.response.data?.detail || e.response.data?.message || ""
errorMsg = detail || `后端错误 (${e.response.status})`
console.error("[Step6] 后端返回:", e.response.data)
} else if (e.request) {
// 请求已发送但无响应
errorMsg = "服务器无响应,请检查网络连接"
console.error("[Step6] 请求无响应:", e.request)
} else if (e.message) {
errorMsg = e.message
}
message.error(errorMsg)
console.warn("[Step6] 智能封面生成失败:", err)
message.error("封面生成失败,请稍后重试")
} finally {
clearTimeout(timeoutId)
setGenerating(false)
generatingRef.current = false
}
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange, generating])
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange])
// ── 模板操作方法 ──
const handleSelectTemplate = useCallback((id: string) => {
@@ -193,7 +165,6 @@ export function useStep6Cover({
return {
coverSettings,
generating,
generateAutoCover,
totalDuration,
showCoverSettings,
@@ -149,6 +149,7 @@ vi.mock("@/api/editing-planner", () => ({
vi.mock("@/api/template-editor", () => ({
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
generateCover: vi.fn().mockResolvedValue({}),
getEditPlan: vi.fn().mockResolvedValue({}),
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
updateEditPlan: vi.fn().mockResolvedValue({}),
@@ -222,6 +223,9 @@ vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({
vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
default: () => React.createElement("div", { "data-testid": "StickerPanel" }),
}))
vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({
default: () => React.createElement("div", { "data-testid": "CoverSelector" }),
}))
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
}))
@@ -256,6 +260,7 @@ vi.mock("@/pages/editing-planner/types", () => ({
DEFAULT_FILTER_CONFIG: { enabled: false },
DEFAULT_CHROMA_KEY_CONFIG: { enabled: false },
DEFAULT_STICKER_CONFIG: { enabled: false },
DEFAULT_COVER_CONFIG: { enabled: false },
}))
vi.mock("@/pages/editing-planner/types/subtitle", () => ({
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import CoverSelector from "@/pages/editing-planner/components/CoverSelector"
import { DEFAULT_COVER_CONFIG } from "@/pages/editing-planner/types"
const defaultProps = {
open: true,
onClose: vi.fn(),
config: DEFAULT_COVER_CONFIG,
onChange: vi.fn(),
totalDuration: 60,
}
describe("CoverSelector", () => {
it("should render without crashing", () => {
const { container } = render(<CoverSelector {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<CoverSelector {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -6,10 +6,15 @@ const defaultProps = {
clips: [],
selectedClipId: null,
isPlaying: false,
currentCoverScheme: "scheme1",
coverSchemes: [{ id: "scheme1", name: "方案1", cover_url: "" }],
aiCoverLoading: false,
titleSettings: undefined,
subtitleSettings: undefined,
onClipSelect: vi.fn(),
onCoverSchemeChange: vi.fn(),
onPlayPause: vi.fn(),
onAiGenerateCover: vi.fn(),
}
describe("PreviewPlayer", () => {
@@ -28,6 +28,7 @@ import "@/pages/editing-planner/utils/clipProperties"
// 子组件
import "@/pages/editing-planner/components/BgmSelector"
import "@/pages/editing-planner/components/ClipPropertiesPanel"
import "@/pages/editing-planner/components/CoverSelector"
import "@/pages/editing-planner/components/EditorClipList"
import "@/pages/editing-planner/components/EditingDrawers"
import "@/pages/editing-planner/components/FilterPanel"
+4 -4
View File
@@ -402,10 +402,10 @@ def _call_ai_cover_service(
logger.info("调用 MediaKit 抽帧: plan_id=%s video=%s", plan_id, primary_video_url[:80])
frames = client.extract_frames(
video_url=primary_video_url,
strategy="SceneChange", # 自动识别画面变化,选最佳帧
max_frames=3, # 减少到 3 帧,平衡速度和质量
poll_interval=2.0, # 缩短轮询间隔
max_poll_attempts=60, # 120秒超时
strategy="TimeInterval",
max_frames=5,
poll_interval=3.0,
max_poll_attempts=60, # 180秒超时
)
if frames and len(frames) > 0:
+400
View File
@@ -0,0 +1,400 @@
"""
封面管理服务单元测试
"""
import subprocess
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from apps.api.app.services.cover_service import (
COVER_STORAGE_PREFIX,
DEFAULT_COVER_HEIGHT,
DEFAULT_COVER_QUALITY,
DEFAULT_COVER_WIDTH,
CoverService,
)
class TestGetCoverConfig:
"""get_cover_config 静态方法测试"""
def test_get_cover_config_default(self):
"""测试默认封面配置"""
config = {}
result = CoverService.get_cover_config(config)
assert result["type"] == "ai_frame"
assert result["image_url"] == ""
assert result["frame_time"] is None
def test_get_cover_config_with_custom_values(self):
"""测试自定义封面配置"""
config = {
"cover": {
"type": "manual",
"image_url": "https://example.com/cover.jpg",
"frame_time": 5.5,
}
}
result = CoverService.get_cover_config(config)
assert result["type"] == "manual"
assert result["image_url"] == "https://example.com/cover.jpg"
assert result["frame_time"] == 5.5
def test_get_cover_config_cover_not_dict(self):
"""测试 cover 不是 dict 时返回默认值"""
config = {"cover": "not-a-dict"}
result = CoverService.get_cover_config(config)
assert result["type"] == "ai_frame"
assert result["image_url"] == ""
assert result["frame_time"] is None
def test_get_cover_config_partial_fields(self):
"""测试部分字段存在时,其余字段用默认值"""
config = {"cover": {"type": "custom"}}
result = CoverService.get_cover_config(config)
assert result["type"] == "custom"
assert result["image_url"] == ""
assert result["frame_time"] is None
def test_get_cover_config_empty_cover_dict(self):
"""测试空的 cover dict"""
config = {"cover": {}}
result = CoverService.get_cover_config(config)
assert result["type"] == "ai_frame"
assert result["image_url"] == ""
class TestExtractCoverFromClip:
"""extract_cover_from_clip 测试"""
@pytest.fixture
def mock_storage(self):
storage = Mock()
storage.download_file = Mock()
storage.upload_file = Mock()
storage.get_url = Mock(return_value="https://oss.example.com/covers/plan1/cover_1000.jpg")
return storage
@pytest.fixture
def mock_asset_repo(self):
repo = Mock()
repo.get = Mock(return_value=None)
return repo
@pytest.fixture
def video_asset(self):
asset = Mock()
asset.storage_key = "videos/test-video.mp4"
asset.mime_type = "video/mp4"
return asset
@pytest.fixture
def service(self, mock_storage, mock_asset_repo):
return CoverService(storage_service=mock_storage, asset_repository=mock_asset_repo)
def test_extract_cover_asset_not_found(self, service, mock_asset_repo):
"""测试素材不存在时报错"""
mock_asset_repo.get.return_value = None
with pytest.raises(ValueError, match="素材不存在"):
service.extract_cover_from_clip(plan_id="plan-1", asset_id="nonexistent")
def test_extract_cover_asset_no_storage_key(self, service, mock_asset_repo):
"""测试素材没有文件时报错"""
asset = Mock()
asset.storage_key = ""
asset.mime_type = "video/mp4"
mock_asset_repo.get.return_value = asset
with pytest.raises(ValueError, match="素材没有文件"):
service.extract_cover_from_clip(plan_id="plan-1", asset_id="asset-no-file")
def test_extract_cover_asset_not_video(self, service, mock_asset_repo):
"""测试非视频素材报错"""
asset = Mock()
asset.storage_key = "images/photo.jpg"
asset.mime_type = "image/jpeg"
mock_asset_repo.get.return_value = asset
with pytest.raises(ValueError, match="素材不是视频类型"):
service.extract_cover_from_clip(plan_id="plan-1", asset_id="asset-img")
def test_extract_cover_download_failure(self, service, mock_asset_repo, mock_storage, video_asset):
"""测试下载素材失败"""
mock_asset_repo.get.return_value = video_asset
mock_storage.download_file.side_effect = Exception("网络错误")
with pytest.raises(RuntimeError, match="下载素材失败"):
service.extract_cover_from_clip(plan_id="plan-1", asset_id="asset-1")
def test_extract_cover_upload_failure(self, service, mock_asset_repo, mock_storage, video_asset):
"""测试上传封面失败"""
mock_asset_repo.get.return_value = video_asset
def fake_download(storage_key, local_path):
# 创建一个假的视频文件
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
with open(local_path, "wb") as f:
f.write(b"fake video data")
mock_storage.download_file.side_effect = fake_download
mock_storage.upload_file.side_effect = Exception("上传失败")
# mock _extract_frame 避免真的调 ffmpeg
with patch.object(CoverService, "_extract_frame") as mock_extract:
def fake_extract(video_path, output_path, **kwargs):
# 创建假的封面文件
with open(output_path, "wb") as f:
f.write(b"\xff\xd8\xff\xe0fake jpeg data")
mock_extract.side_effect = fake_extract
with pytest.raises(RuntimeError, match="上传封面失败"):
service.extract_cover_from_clip(plan_id="plan-1", asset_id="asset-1")
def test_extract_cover_get_url_falls_back_to_key(self, service, mock_asset_repo, mock_storage, video_asset):
"""测试获取 URL 失败时降级为 storage_key"""
mock_asset_repo.get.return_value = video_asset
def fake_download(storage_key, local_path):
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
with open(local_path, "wb") as f:
f.write(b"fake video data")
mock_storage.download_file.side_effect = fake_download
mock_storage.get_url.side_effect = Exception("URL服务不可用")
with patch.object(CoverService, "_extract_frame") as mock_extract:
def fake_extract(video_path, output_path, **kwargs):
with open(output_path, "wb") as f:
f.write(b"\xff\xd8\xff\xe0fake jpeg")
mock_extract.side_effect = fake_extract
result = service.extract_cover_from_clip(plan_id="plan-abc", asset_id="asset-xyz", frame_time=2.5)
assert result["type"] == "manual"
assert result["frame_time"] == 2.5
# URL 失败时返回 storage_key
assert COVER_STORAGE_PREFIX in result["image_url"]
assert "plan-abc" in result["image_url"]
def test_extract_cover_success(self, service, mock_asset_repo, mock_storage, video_asset):
"""测试抽帧成功完整流程"""
mock_asset_repo.get.return_value = video_asset
def fake_download(storage_key, local_path):
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
with open(local_path, "wb") as f:
f.write(b"fake video data for testing")
mock_storage.download_file.side_effect = fake_download
with patch.object(CoverService, "_extract_frame") as mock_extract:
def fake_extract(video_path, output_path, **kwargs):
with open(output_path, "wb") as f:
f.write(b"\xff\xd8\xff\xe0fake jpeg image data")
mock_extract.side_effect = fake_extract
result = service.extract_cover_from_clip(
plan_id="plan-123",
asset_id="asset-456",
frame_time=3.0,
width=720,
height=1280,
quality=3,
)
assert result["type"] == "manual"
assert result["image_url"] == "https://oss.example.com/covers/plan1/cover_1000.jpg"
assert result["frame_time"] == 3.0
# 验证上传被调用
mock_storage.upload_file.assert_called_once()
upload_args = mock_storage.upload_file.call_args[1]
assert upload_args["content_type"] == "image/jpeg"
assert "plan-123" in upload_args["storage_key"]
assert "3000" in upload_args["storage_key"] # frame_time * 1000
# 验证 _extract_frame 被调用且参数正确
mock_extract.assert_called_once()
extract_kwargs = mock_extract.call_args[1]
assert extract_kwargs["time_sec"] == 3.0
assert extract_kwargs["width"] == 720
assert extract_kwargs["height"] == 1280
assert extract_kwargs["quality"] == 3
class TestGenerateSmartCover:
"""generate_smart_cover 测试"""
@pytest.fixture
def service(self):
return CoverService(storage_service=Mock(), asset_repository=Mock())
def test_generate_smart_cover_calls_extract_with_default_time(self, service):
"""测试智能封面调用 extract_cover_from_clip 并设置 type 为 ai_frame"""
fake_result = {"type": "manual", "image_url": "test.jpg", "frame_time": 3.0}
with patch.object(service, "extract_cover_from_clip", return_value=fake_result) as mock_extract:
result = service.generate_smart_cover(plan_id="plan-1", asset_id="asset-1")
mock_extract.assert_called_once()
call_kwargs = mock_extract.call_args[1]
assert call_kwargs["plan_id"] == "plan-1"
assert call_kwargs["asset_id"] == "asset-1"
assert call_kwargs["frame_time"] == 3.0 # 默认第3秒
assert result["type"] == "ai_frame"
assert result["image_url"] == "test.jpg"
def test_generate_smart_cover_passes_dimensions(self, service):
"""测试智能封面传递尺寸和质量参数"""
fake_result = {"type": "manual", "image_url": "test.jpg", "frame_time": 3.0}
with patch.object(service, "extract_cover_from_clip", return_value=fake_result) as mock_extract:
service.generate_smart_cover(
plan_id="plan-1",
asset_id="asset-1",
width=1080,
height=1920,
quality=5,
)
call_kwargs = mock_extract.call_args[1]
assert call_kwargs["width"] == 1080
assert call_kwargs["height"] == 1920
assert call_kwargs["quality"] == 5
class TestExtractFrame:
"""_extract_frame 静态方法测试(mock subprocess"""
@pytest.fixture
def video_path(self, tmp_path):
path = tmp_path / "test_video.mp4"
path.write_bytes(b"fake video")
return path
@pytest.fixture
def output_path(self, tmp_path):
return tmp_path / "cover.jpg"
def test_extract_frame_success(self, video_path, output_path):
"""测试 FFmpeg 抽帧成功"""
fake_result = Mock()
fake_result.returncode = 0
with patch("subprocess.run", return_value=fake_result) as mock_run:
CoverService._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=2.5,
width=1080,
height=1920,
quality=5,
)
assert mock_run.call_count == 1
cmd = mock_run.call_args[0][0]
assert cmd[0] == "ffmpeg"
assert "-ss" in cmd
assert "2.500" in cmd
assert "-vframes" in cmd
# 验证 scale+crop 滤镜存在
vf_index = cmd.index("-vf") + 1
assert "scale=" in cmd[vf_index]
assert "crop=" in cmd[vf_index]
def test_extract_frame_fallback_to_simple_command(self, video_path, output_path):
"""测试主命令失败时回退到简化命令"""
fail_result = Mock()
fail_result.returncode = 1
fail_result.stderr = "Filter graph error"
success_result = Mock()
success_result.returncode = 0
call_count = 0
def fake_run(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return fail_result
return success_result
with patch("subprocess.run", side_effect=fake_run) as mock_run:
CoverService._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=1.0,
width=1080,
height=1920,
quality=5,
)
assert mock_run.call_count == 2
# 第二次是简化命令(没有 -vf 参数)
second_cmd = mock_run.call_args_list[1][0][0]
assert "-vf" not in second_cmd
def test_extract_frame_both_commands_fail(self, video_path, output_path):
"""测试两个命令都失败时报错"""
fail_result = Mock()
fail_result.returncode = 1
fail_result.stderr = "Invalid data found when processing input"
with patch("subprocess.run", return_value=fail_result):
with pytest.raises(RuntimeError, match="FFmpeg 抽帧失败"):
CoverService._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=1.0,
width=1080,
height=1920,
quality=5,
)
def test_extract_frame_timeout(self, video_path, output_path):
"""测试 FFmpeg 抽帧超时"""
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=60)):
with pytest.raises(RuntimeError, match="FFmpeg 抽帧超时"):
CoverService._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=1.0,
width=1080,
height=1920,
quality=5,
)
def test_extract_frame_ffmpeg_not_found(self, video_path, output_path):
"""测试 FFmpeg 不可用"""
with patch("subprocess.run", side_effect=FileNotFoundError("ffmpeg not found")):
with pytest.raises(RuntimeError, match="FFmpeg 不可用"):
CoverService._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=1.0,
width=1080,
height=1920,
quality=5,
)
class TestDefaults:
"""默认常量测试"""
def test_default_dimensions(self):
"""测试默认尺寸常量"""
assert DEFAULT_COVER_WIDTH == 1080
assert DEFAULT_COVER_HEIGHT == 1920
assert DEFAULT_COVER_QUALITY == 5
assert COVER_STORAGE_PREFIX == "covers"