Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2bf9af6de | |||
| 8799eb8ff5 | |||
| caa5113f7f | |||
| 4efc44e0dc | |||
| 3c800d3f3f | |||
| e2d10dd357 | |||
| c62125cf7b |
@@ -207,13 +207,10 @@ const GeneratePage: React.FC = () => {
|
||||
onRetry={handleRetryGenerate}
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
/* Step5 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step5Preview.items}
|
||||
previewSelectedIndex={step5Preview.selectedIndex}
|
||||
onSelectPreview={step5Preview.setSelectedIndex}
|
||||
previewOverallStatus={step5Preview.previewStatus}
|
||||
previewOverallError={step5Preview.previewError}
|
||||
previewOverallProgress={step5Preview.progress}
|
||||
|
||||
@@ -63,13 +63,10 @@ export interface GenerateStepContentProps {
|
||||
onDismissError: () => void
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
videoRatio: string
|
||||
/* Step4 预览(多预览) */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
previewItems: PreviewItem[]
|
||||
previewSelectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
previewOverallStatus: PreviewStatus
|
||||
previewOverallError: string
|
||||
previewOverallProgress: number
|
||||
@@ -110,12 +107,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onRetry,
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
previewItems,
|
||||
previewSelectedIndex,
|
||||
onSelectPreview,
|
||||
previewOverallStatus,
|
||||
previewOverallError,
|
||||
previewOverallProgress,
|
||||
@@ -161,12 +155,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
case 5:
|
||||
return (
|
||||
<Step5GeneratePreview
|
||||
videoRatio={videoRatio}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
items={previewItems}
|
||||
selectedIndex={previewSelectedIndex}
|
||||
onSelectPreview={onSelectPreview}
|
||||
overallStatus={previewOverallStatus}
|
||||
overallError={previewOverallError}
|
||||
overallProgress={previewOverallProgress}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Step 5 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
* 调用后端预览生成接口,展示预览生成状态
|
||||
* 预览视频由右侧 PreviewVideoPanel 展示,本组件不重复显示预览网格
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
@@ -15,12 +15,9 @@ import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
|
||||
|
||||
interface Step5GeneratePreviewProps {
|
||||
videoRatio: string
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
items: PreviewItem[]
|
||||
selectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
overallStatus: PreviewStatus
|
||||
overallError: string
|
||||
overallProgress: number
|
||||
@@ -37,12 +34,9 @@ const PREVIEW_COUNT_OPTIONS = [
|
||||
]
|
||||
|
||||
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelectPreview,
|
||||
overallStatus,
|
||||
overallError,
|
||||
overallProgress,
|
||||
@@ -50,7 +44,6 @@ const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
|
||||
const isIdle = overallStatus === "idle"
|
||||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||||
|
||||
@@ -127,109 +120,7 @@ const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||||
<div
|
||||
className="xx-preview-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||||
gap: 12,
|
||||
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
|
||||
margin: "0 auto 16px",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.index === selectedIndex
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
onClick={() => {
|
||||
if (item.status === "ready") onSelectPreview(item.index)
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||||
overflow: "hidden",
|
||||
cursor: item.status === "ready" ? "pointer" : "default",
|
||||
opacity: item.status === "error" ? 0.6 : 1,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{/* 轻量卡片:深色背景 + 状态指示 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio,
|
||||
background: "#1a1a2e",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{/* 中心:预览编号 */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</span>
|
||||
|
||||
{/* 状态指示 */}
|
||||
{item.status === "generating" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<LoadingOutlined style={{ fontSize: 14, color: "#fff" }} spin />
|
||||
<span style={{ color: "rgba(255,255,255,0.8)", fontSize: 12 }}>
|
||||
生成中 {item.progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "pending" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<ClockCircleOutlined
|
||||
style={{ fontSize: 14, color: "rgba(255,255,255,0.6)" }}
|
||||
/>
|
||||
<span style={{ color: "rgba(255,255,255,0.6)", fontSize: 12 }}>
|
||||
排队中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "ready" && (
|
||||
<CheckCircleFilled style={{ fontSize: 18, color: "#52c41a" }} />
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<ExclamationCircleFilled style={{ fontSize: 18, color: "#ef4444" }} />
|
||||
)}
|
||||
|
||||
{/* 选中角标 */}
|
||||
{isSelected && item.status === "ready" && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
fontSize: 10,
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 预览网格已移除 — 右侧 PreviewVideoPanel 已展示实际预览视频,左侧不再重复显示 */}
|
||||
|
||||
{/* 整体进度条(多预览生成中) */}
|
||||
{anyGenerating && (
|
||||
@@ -244,7 +135,7 @@ const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>
|
||||
{items.filter((it) => it.status === "ready").length} 个预览生成成功
|
||||
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
|
||||
",请在右侧预览面板查看效果"
|
||||
</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
|
||||
@@ -31,14 +31,27 @@ vi.mock("react-router-dom", async () => {
|
||||
})
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: any) =>
|
||||
selector({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
useAuthStore: Object.assign(
|
||||
(selector: any) =>
|
||||
selector({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
{
|
||||
getState: () => ({
|
||||
user: { id: "1", username: "testuser" },
|
||||
token: "mock-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
isAuthenticated: true,
|
||||
setAuth: mockSetAuth,
|
||||
clearAuth: mockClearAuth,
|
||||
}),
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
@@ -108,21 +108,30 @@ def _run_with_fakes(
|
||||
session = MagicMock()
|
||||
session_maker = MagicMock(return_value=session)
|
||||
|
||||
captured = {"upload_calls": [], "session": session_maker()}
|
||||
captured: dict = {"upload_calls": []}
|
||||
|
||||
def _tracking_upload(local_path, storage_key):
|
||||
captured["upload_calls"].append((local_path, storage_key))
|
||||
return upload_fn(local_path, storage_key)
|
||||
|
||||
# Wrap session_maker to capture the session INSIDE the patch context.
|
||||
# This avoids stale PromiseProxy cache issues in full-suite runs.
|
||||
_created_sessions: list = []
|
||||
_orig_sm = session_maker
|
||||
|
||||
def _tracking_sm(*a, **kw):
|
||||
s = _orig_sm(*a, **kw)
|
||||
_created_sessions.append(s)
|
||||
return s
|
||||
|
||||
bound_task = _make_bound_task()
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=repo,
|
||||
):
|
||||
import worker_app.db as _db_mod
|
||||
|
||||
with patch.object(_db_mod, "SessionLocal", _tracking_sm):
|
||||
with patch(
|
||||
"worker_app.db.SessionLocal",
|
||||
session_maker,
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=repo,
|
||||
):
|
||||
with patch(
|
||||
"video_processing.oss_helpers.upload_to_oss",
|
||||
@@ -134,6 +143,7 @@ def _run_with_fakes(
|
||||
):
|
||||
result = _call_task(batch_download_videos, bound_task, [v.id for v in videos], user_id)
|
||||
|
||||
captured["session"] = _created_sessions[0] if _created_sessions else None
|
||||
captured["result"] = result
|
||||
return captured
|
||||
|
||||
@@ -244,19 +254,67 @@ def test_batch_download_single_video():
|
||||
|
||||
|
||||
def test_batch_download_session_closed():
|
||||
"""DB session is always closed (via finally block)."""
|
||||
"""DB session is always closed (via finally block).
|
||||
|
||||
Patches the function's own globals to inject mock SessionLocal,
|
||||
bypassing any import caching issues in the full suite.
|
||||
"""
|
||||
import sys
|
||||
import worker_app.db as _db_mod
|
||||
|
||||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||||
|
||||
videos = [_FakeVideo("v1", "v.mp4")]
|
||||
repo = _FakeGeneratedVideoRepository(videos)
|
||||
|
||||
session = MagicMock()
|
||||
session_maker = MagicMock(return_value=session)
|
||||
mock_session_factory = MagicMock(return_value=session)
|
||||
|
||||
_run_with_fakes(videos, session_maker=session_maker)
|
||||
def _noop_download(url, dest):
|
||||
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(dest).write_bytes(b"fake video data")
|
||||
|
||||
bound_task = _make_bound_task()
|
||||
|
||||
# Get the raw function to patch its globals
|
||||
raw_fn = _get_raw_task_fn(batch_download_videos)
|
||||
|
||||
# Patch SessionLocal in ALL possible module locations
|
||||
_db_mod.SessionLocal = mock_session_factory
|
||||
if "worker_app.db" in sys.modules:
|
||||
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
|
||||
|
||||
# Also patch in the function's own globals if it has a reference there
|
||||
if "SessionLocal" in raw_fn.__globals__:
|
||||
raw_fn.__globals__["SessionLocal"] = mock_session_factory
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=repo,
|
||||
):
|
||||
with patch("video_processing.oss_helpers.upload_to_oss", return_value="https://oss.example.com/zip"):
|
||||
with patch(
|
||||
"apps.worker.worker_app.tasks.batch_download._download_video_to_file",
|
||||
_noop_download,
|
||||
):
|
||||
raw_fn(bound_task, ["v1"], "user_1")
|
||||
finally:
|
||||
pass # Don't restore - other tests handle their own patches
|
||||
|
||||
# Diagnostic: check if our mock factory was actually called
|
||||
assert mock_session_factory.called, (
|
||||
"SessionLocal mock was never called! "
|
||||
f"raw_fn={raw_fn}, type={type(raw_fn)}"
|
||||
)
|
||||
session.close.assert_called_once()
|
||||
|
||||
|
||||
def test_batch_download_closes_session_on_error():
|
||||
"""Session is closed even when get_by_ids raises."""
|
||||
import sys
|
||||
import worker_app.db as _db_mod
|
||||
|
||||
from apps.worker.worker_app.tasks.batch_download import batch_download_videos
|
||||
|
||||
class _ExplodingRepo:
|
||||
@@ -264,17 +322,25 @@ def test_batch_download_closes_session_on_error():
|
||||
raise RuntimeError("db down")
|
||||
|
||||
session = MagicMock()
|
||||
session_maker = MagicMock(return_value=session)
|
||||
mock_session_factory = MagicMock(return_value=session)
|
||||
bound_task = _make_bound_task()
|
||||
|
||||
raw_fn = _get_raw_task_fn(batch_download_videos)
|
||||
|
||||
_db_mod.SessionLocal = mock_session_factory
|
||||
if "worker_app.db" in sys.modules:
|
||||
sys.modules["worker_app.db"].SessionLocal = mock_session_factory
|
||||
if "SessionLocal" in raw_fn.__globals__:
|
||||
raw_fn.__globals__["SessionLocal"] = mock_session_factory
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
||||
return_value=_ExplodingRepo(),
|
||||
):
|
||||
with patch("worker_app.db.SessionLocal", session_maker):
|
||||
with pytest.raises(RuntimeError, match="db down"):
|
||||
_call_task(batch_download_videos, bound_task, ["v1"], "u")
|
||||
with pytest.raises(RuntimeError, match="db down"):
|
||||
raw_fn(bound_task, ["v1"], "u")
|
||||
|
||||
assert mock_session_factory.called, "SessionLocal mock was never called!"
|
||||
session.close.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ def _make_mock_clip(clip_id="clip-001", order=0, duration=10.0, clip_type="video
|
||||
clip.config = {}
|
||||
clip.asset_id = "asset-001"
|
||||
clip.status = "ready"
|
||||
clip.template_clip_config_id = ""
|
||||
clip.created_at = None
|
||||
clip.updated_at = None
|
||||
return clip
|
||||
|
||||
|
||||
@@ -327,7 +330,8 @@ class TestClipSplitMerge:
|
||||
resp = c.post(BASE + "/clips/merge", json={"clip_ids": ["clip-001", "clip-002"]})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
assert "merged_clip" in data
|
||||
assert "id" in data["merged_clip"]
|
||||
mock_plan_svc.merge_clips.assert_called_once_with(["clip-001", "clip-002"])
|
||||
|
||||
def test_merge_clips_single_422(self, client):
|
||||
|
||||
Reference in New Issue
Block a user