Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de8f06058c | |||
| 7065c93cd3 | |||
| b08b7be967 | |||
| f8276ed4c8 | |||
| a0ad4274e6 | |||
| b6d2a19ad7 | |||
| a5eeb0a441 | |||
| 361c4fea38 | |||
| 00d2e4714c | |||
| 48be85369a | |||
| b474cc58d8 | |||
| 85568b4d56 | |||
| 5a7566d2bc | |||
| 9f910e0c7f | |||
| 7c71f15dd0 | |||
| 4f308926bd | |||
| 5e377d18a4 | |||
| ada1c19b49 |
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.api.routes._helpers import check_project_access, format_utc_datetime
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -14,7 +14,6 @@ from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchClassifyRequest,
|
||||
BatchDeleteRequest,
|
||||
BatchGetRequest,
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
@@ -371,18 +370,6 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=List[AssetResponse])
|
||||
def batch_get_assets(
|
||||
request: BatchGetRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> list[AssetResponse]:
|
||||
"""批量获取素材详情(根据 ID 列表)。"""
|
||||
items = asset_repository.find_by_ids(request.ids)
|
||||
storage_service = get_storage_service()
|
||||
return [_to_asset_response(item, storage_service) for item in items]
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=BatchOperationResponse)
|
||||
def batch_delete_assets(
|
||||
request: BatchDeleteRequest,
|
||||
|
||||
@@ -58,12 +58,6 @@ class AssetResponse(BaseModel):
|
||||
MAX_BATCH_SIZE = 200
|
||||
|
||||
|
||||
class BatchGetRequest(BaseModel):
|
||||
"""批量获取素材详情请求。"""
|
||||
|
||||
ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求(软删除)。"""
|
||||
|
||||
|
||||
@@ -64,11 +64,8 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
const [ready, setReady] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// 稳定化 assetIds:只有内容真正变化时才更新引用
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!stableAssetIds.length || !enabled) {
|
||||
if (!assetIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
@@ -79,7 +76,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(stableAssetIds)
|
||||
const result = await fetchAssetsByIds(assetIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
@@ -95,7 +92,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [stableAssetIds, enabled])
|
||||
}, [assetIds, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
@@ -104,22 +101,4 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
return { assets, loading, ready, reload: load }
|
||||
}
|
||||
|
||||
/**
|
||||
* useStableArray — 数组内容稳定化 Hook
|
||||
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
|
||||
*/
|
||||
function useStableArray<T>(array: T[]): T[] {
|
||||
const ref = useRef<T[]>(array)
|
||||
|
||||
// 比较数组内容是否真正变化
|
||||
const hasChanged =
|
||||
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
|
||||
|
||||
if (hasChanged) {
|
||||
ref.current = array
|
||||
}
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default usePreviewAssets
|
||||
|
||||
@@ -47,8 +47,7 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step5GeneratePreview"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import "@/pages/generate/hooks/useStep5Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -36,6 +37,8 @@ from packages.domain.bgm_utils import merge_bgm_config
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
OUTPUT_FPS = 25.0
|
||||
OUTPUT_DURATION_SECONDS = 5.0
|
||||
GENERATED_FILES_DIR = Path(os.getenv("GENERATED_FILES_DIR", "/app/generated"))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -152,6 +155,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
from video_processing.oss_helpers import (
|
||||
download_asset,
|
||||
get_signed_download_url,
|
||||
@@ -326,6 +330,61 @@ def _build_plan_and_clips_from_task(
|
||||
return plan, clips, asset_path_map
|
||||
|
||||
|
||||
def _create_fallback_clip(output_path: Path, title: str) -> None:
|
||||
"""创建 fallback 视频(无素材时)"""
|
||||
safe_title = title.replace(":", "\\:").replace("'", "\\'")[:80]
|
||||
run_ffmpeg(
|
||||
[
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}",
|
||||
"-vf",
|
||||
f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _mux_audio_track(video_path: Path, audio_path: str, output_path: Path) -> None:
|
||||
"""将音频轨混入已渲染的视频(后处理步骤)。
|
||||
|
||||
使用 FFmpeg 将视频和音频合并,视频时长为准,音频不足则循环,
|
||||
音频过长则截断。
|
||||
"""
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-i",
|
||||
audio_path,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-shortest",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
run_ffmpeg(command)
|
||||
|
||||
|
||||
def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
"""下载配音文件。
|
||||
|
||||
@@ -369,6 +428,105 @@ def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
return download_asset(storage_key, local_path)
|
||||
|
||||
|
||||
def _prepare_bgm_track(
|
||||
*,
|
||||
bgm_config: dict,
|
||||
temp_path: Path,
|
||||
task_id: str = "",
|
||||
) -> str | None:
|
||||
"""准备 BGM 音频文件(下载到本地).
|
||||
|
||||
支持 3 种来源(按优先级):
|
||||
1. audio_url — 外部直链 URL(最高优先级)
|
||||
2. asset_id — 素材库中的音频素材
|
||||
3. preset_id — 预设 BGM 库
|
||||
|
||||
Returns:
|
||||
BGM 本地文件路径,准备失败返回 None
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
audio_url = bgm_config.get("audio_url", "") or ""
|
||||
asset_id = bgm_config.get("asset_id", "") or ""
|
||||
preset_id = bgm_config.get("preset_id", "") or ""
|
||||
|
||||
bgm_file = temp_path / f"bgm_{task_id or 'track'}.mp3"
|
||||
|
||||
# 优先级1:外部直链 URL
|
||||
if audio_url:
|
||||
try:
|
||||
parsed = urlparse(audio_url)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
|
||||
safe_download_file(
|
||||
audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] URL下载失败: %s", task_id, e)
|
||||
|
||||
# 优先级2:素材库素材
|
||||
if asset_id:
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
model = session.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model and (model.storage_key or model.file_url):
|
||||
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
|
||||
storage_key = model.storage_key or model.file_url
|
||||
logger.info("[task_id=%s] [BGM] 从素材库下载: asset_id=%s", task_id, asset_id)
|
||||
ok = download_asset(storage_key, bgm_file)
|
||||
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] 素材库下载失败: %s", task_id, e)
|
||||
|
||||
# 优先级3:预设 BGM 库
|
||||
if preset_id:
|
||||
try:
|
||||
from packages.domain.preset_bgm import get_preset_bgm
|
||||
|
||||
preset = get_preset_bgm(preset_id)
|
||||
if preset and preset.audio_url:
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
|
||||
safe_download_file(
|
||||
preset.audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_preset_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] 预设库下载失败: %s", task_id, e)
|
||||
|
||||
# 所有来源都失败
|
||||
logger.warning("[task_id=%s] [BGM] 所有来源都无法获取BGM,跳过", task_id)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_url_accessible(
|
||||
url: str,
|
||||
timeout: float = 10.0,
|
||||
|
||||
@@ -14,7 +14,11 @@ import pytest
|
||||
from video_processing.unified_render_service import (
|
||||
UnifiedRenderService,
|
||||
)
|
||||
from worker_app.tasks.generation import _build_plan_and_clips_from_task
|
||||
from worker_app.tasks.generation import (
|
||||
_build_plan_and_clips_from_task,
|
||||
_create_fallback_clip,
|
||||
_mux_audio_track,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("ffmpeg"),
|
||||
@@ -58,6 +62,61 @@ def _generate_test_audio(path: Path, duration: float = 5.0) -> None:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
|
||||
|
||||
|
||||
# ── 测试 _create_fallback_clip ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFallbackClip:
|
||||
"""测试 fallback 视频生成。"""
|
||||
|
||||
def test_fallback_clip_creates_video(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "fallback.mp4"
|
||||
_create_fallback_clip(output, "Test Fallback")
|
||||
|
||||
assert output.exists()
|
||||
assert output.stat().st_size > 0
|
||||
|
||||
|
||||
# ── 测试 _mux_audio_track ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMuxAudioTrack:
|
||||
"""测试视频+音频混合。"""
|
||||
|
||||
def test_mux_audio_into_video(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
video_path = Path(tmpdir) / "video.mp4"
|
||||
audio_path = Path(tmpdir) / "audio.aac"
|
||||
output_path = Path(tmpdir) / "output.mp4"
|
||||
|
||||
_generate_test_video(video_path, duration=3.0)
|
||||
_generate_test_audio(audio_path, duration=5.0)
|
||||
|
||||
_mux_audio_track(video_path, str(audio_path), output_path)
|
||||
|
||||
assert output_path.exists()
|
||||
assert output_path.stat().st_size > 0
|
||||
|
||||
# 验证输出文件包含音频轨
|
||||
probe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-show_streams",
|
||||
"-select_streams",
|
||||
"a",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
|
||||
# 如果有音频流,输出非空
|
||||
assert result.stdout.strip() != "" or result.returncode == 0
|
||||
|
||||
|
||||
# ── 测试 PlanGenerator → UnifiedRenderService 全链路 ─────────────────────────
|
||||
|
||||
|
||||
class TestFullPipeline:
|
||||
"""验证从虚拟 plan 构建到渲染输出的完整流程。"""
|
||||
|
||||
@@ -116,26 +175,12 @@ class TestFullPipeline:
|
||||
)
|
||||
render_result = service.render()
|
||||
|
||||
# 混音 - 直接用 ffmpeg(_mux_audio_track 已被清理)
|
||||
# 混音
|
||||
audio_path = work_dir / "voice.aac"
|
||||
_generate_test_audio(audio_path, duration=5.0)
|
||||
|
||||
final_path = work_dir / "final.mp4"
|
||||
mux_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(render_result.output_path),
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-shortest",
|
||||
str(final_path),
|
||||
]
|
||||
subprocess.run(mux_cmd, check=True, capture_output=True, timeout=30)
|
||||
_mux_audio_track(render_result.output_path, str(audio_path), final_path)
|
||||
|
||||
assert final_path.exists()
|
||||
assert final_path.stat().st_size > 0
|
||||
|
||||
Reference in New Issue
Block a user