Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2098b03ab | |||
| 40eca5e83f | |||
| 4cf6f222ed | |||
| 0fde7d687c |
@@ -52,12 +52,37 @@ export const getAssetsByKind = async (
|
||||
/**
|
||||
* 智能匹配素材(后端 AI 选素材)
|
||||
* 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材
|
||||
*
|
||||
* 后端返回 items 元素兼容两种结构(过渡期):
|
||||
* - 扁平结构:AssetItem 本身(id 在顶层)
|
||||
* - 包装结构:{ asset: AssetItem, score, breakdown }(id 需从 .asset 取)
|
||||
* 这里统一归一化为 AssetItem[],调用方无需关心包装层。
|
||||
*/
|
||||
export const smartMatchAssets = async (libraryId: string): Promise<{ items: AssetItem[] }> => {
|
||||
export interface SmartMatchResult {
|
||||
items: AssetItem[]
|
||||
}
|
||||
|
||||
interface SmartMatchWrappedItem {
|
||||
asset?: AssetItem
|
||||
id?: string
|
||||
score?: number
|
||||
breakdown?: unknown
|
||||
}
|
||||
|
||||
export const smartMatchAssets = async (libraryId: string): Promise<SmartMatchResult> => {
|
||||
const response = await apiClient.post("/assets/smart-match", {
|
||||
library_id: libraryId,
|
||||
})
|
||||
return response.data
|
||||
const rawItems: SmartMatchWrappedItem[] = response.data?.items ?? []
|
||||
const items = rawItems
|
||||
.map((it) =>
|
||||
// 包装结构 { asset: {...} } 优先解包;否则视其本身为扁平 AssetItem
|
||||
it?.asset && typeof it.asset === "object" && "id" in it.asset
|
||||
? it.asset
|
||||
: (it as unknown as AssetItem),
|
||||
)
|
||||
.filter((it): it is AssetItem => !!it && typeof it.id === "string" && it.id.length > 0)
|
||||
return { items }
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
|
||||
@@ -147,44 +147,52 @@
|
||||
/* ============================================================
|
||||
上传区域
|
||||
============================================================ */
|
||||
.xx-asset-upload-zone {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2xl) var(--space-xl);
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
.xx-asset-upload-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px dashed transparent;
|
||||
border-radius: var(--radius-md);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.xx-asset-upload-zone:hover {
|
||||
.xx-asset-upload-entry-dragover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-asset-upload-zone:active {
|
||||
border-style: solid;
|
||||
transform: scale(0.99);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.xx-asset-upload-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-asset-upload-text {
|
||||
font-size: var(--font-size-base) !important;
|
||||
color: var(--text-primary) !important;
|
||||
margin: 0 0 var(--space-xs) !important;
|
||||
.xx-asset-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 6px 16px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-inverse);
|
||||
background: var(--primary-color);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-asset-upload-hint {
|
||||
font-size: var(--font-size-sm) !important;
|
||||
color: var(--text-tertiary) !important;
|
||||
margin: 0 !important;
|
||||
.xx-asset-upload-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.xx-asset-upload-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.xx-asset-upload-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -397,26 +405,46 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-sm);
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 状态标签 + 余量角标行 */
|
||||
.xx-asset-meta-left {
|
||||
/* 状态标签行:标签过长省略 */
|
||||
.xx-asset-meta-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-asset-meta-status .xx-status-pill {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-asset-meta-duration {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 余量标签独占一行 */
|
||||
.xx-asset-meta-usage {
|
||||
justify-content: flex-start;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 视频素材余量角标(仅状态展示,不影响卡片操作) */
|
||||
.xx-asset-usage-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
@@ -481,12 +509,16 @@
|
||||
.xx-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
gap: 2px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.xx-status-pill-ok {
|
||||
|
||||
@@ -128,16 +128,18 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<span className="xx-asset-meta-left">
|
||||
<span className="xx-asset-meta-status">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
{usageBadge && (
|
||||
<span className={`xx-asset-usage-badge xx-asset-usage-badge-${usageBadge.variant}`}>
|
||||
{usageBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
{asset.duration && <span className="xx-asset-meta-duration">{asset.duration}</span>}
|
||||
</div>
|
||||
{usageBadge && (
|
||||
<div className="xx-asset-meta xx-asset-meta-usage">
|
||||
<span className={`xx-asset-usage-badge xx-asset-usage-badge-${usageBadge.variant}`}>
|
||||
{usageBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* AssetLibrary 上传拖拽区域
|
||||
* 多文件拖入即入队(并发由 useAssetUpload 队列控制,最多 3 路直传)
|
||||
* AssetLibrary 上传入口(紧凑按钮模式)
|
||||
* - 点击按钮打开文件选择(多选),多文件入队由 useAssetUpload 队列控制(最多 3 路直传)
|
||||
* - 拖拽文件到内容区任意位置同样触发上传(不再占用大面积虚线框)
|
||||
*/
|
||||
import React from "react"
|
||||
import { Upload } from "antd"
|
||||
import { InboxOutlined } from "@ant-design/icons"
|
||||
import React, { useRef, useState } from "react"
|
||||
import { PlusOutlined, CloudUploadOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface AssetUploadZoneProps {
|
||||
uploading: boolean
|
||||
@@ -19,30 +19,73 @@ export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
pendingCount,
|
||||
onUpload,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// dragenter/dragleave 在经过子元素时会成对触发,用计数器避免高亮闪烁;
|
||||
// 计数器归零(拖拽真正离开容器)才取消高亮
|
||||
const dragDepthRef = useRef(0)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const pickFiles = (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return
|
||||
onUpload(Array.from(list))
|
||||
}
|
||||
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
// antd 多选时会对每个文件同步连续触发一次 beforeUpload;
|
||||
// 每次只入队当前文件,React 批处理保证多文件一次性渲染
|
||||
onUpload([file as File])
|
||||
return false
|
||||
<div
|
||||
className={`xx-asset-upload-entry${dragOver ? " xx-asset-upload-entry-dragover" : ""}`}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDragOver(false)
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
dragDepthRef.current = 0
|
||||
setDragOver(false)
|
||||
pickFiles(e.dataTransfer.files)
|
||||
}}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="xx-asset-upload-text">
|
||||
{uploading
|
||||
? `上传中…(进行 ${activeCount} 个${pendingCount > 0 ? `,排队 ${pendingCount} 个` : ""})`
|
||||
: "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">支持视频、图片,单文件不超过 2GB;多文件自动排队上传</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-asset-upload-btn"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<PlusOutlined />
|
||||
上传素材
|
||||
</button>
|
||||
<span className="xx-asset-upload-status">
|
||||
{uploading ? (
|
||||
<>
|
||||
<CloudUploadOutlined />
|
||||
上传中…(进行 {activeCount} 个{pendingCount > 0 ? `,排队 ${pendingCount} 个` : ""})
|
||||
</>
|
||||
) : (
|
||||
"视频、图片均可,单文件不超过 2GB;也可直接拖拽文件到此区域"
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
pickFiles(e.target.files)
|
||||
// 允许连续选择同一文件
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ const getFileSize = (item: AssetItem): number => {
|
||||
return item.file_size ?? (item.metadata?.file_size as number) ?? 0
|
||||
}
|
||||
|
||||
/** 是否为 AI 音色(克隆/预置音色模型:无固定时长、无实体音频文件,按脚本实时合成) */
|
||||
const isAiVoice = (item: AssetItem): boolean => {
|
||||
const duration = getDuration(item)
|
||||
const size = getFileSize(item)
|
||||
return (!duration || duration <= 0) && (!size || size <= 0)
|
||||
}
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
@@ -96,10 +103,10 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
/** 选中素材(含时长校验) */
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
// 如果启用了时长校验,且配音时长不足
|
||||
// 如果启用了时长校验,且配音时长不足(AI 音色按脚本实时合成,不参与时长校验)
|
||||
if (totalVideoDuration > 0) {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (material && getDuration(material) < totalVideoDuration) {
|
||||
if (material && !isAiVoice(material) && getDuration(material) < totalVideoDuration) {
|
||||
setPendingVoiceId(id)
|
||||
setDurationWarningOpen(true)
|
||||
return
|
||||
@@ -280,25 +287,29 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{formatDuration(getDuration(item))}
|
||||
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatFileSize(getFileSize(item))}</span>
|
||||
{isAiVoice(item) ? (
|
||||
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI 音色</span>
|
||||
) : (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{formatDuration(getDuration(item))}
|
||||
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -43,9 +43,11 @@ export function useSmartMatch({
|
||||
|
||||
try {
|
||||
// 调用后端智能匹配 API(后端也会排除已用尽素材,这里前端兜底过滤)
|
||||
// items 已在 API 层归一化(兼容后端 {asset, score} 包装结构);
|
||||
// 这里再过滤一遍无 id/已用尽项,杜绝 undefined id 流入预览链路
|
||||
const result = await smartMatchAssets(libraryId)
|
||||
const matched = (result.items ?? []).filter(isAssetUsable)
|
||||
const matchedIds = matched.map((a: AssetItem) => a.id)
|
||||
const matched = (result.items ?? []).filter((a) => !!a?.id && isAssetUsable(a))
|
||||
const matchedIds = matched.map((a) => a.id)
|
||||
|
||||
if (matchedIds.length > 0) {
|
||||
onSmartSelectedIdsChange(matchedIds)
|
||||
|
||||
@@ -15,12 +15,14 @@ import type { AxiosResponse } from "axios"
|
||||
* 使用 Promise.allSettled 确保单个失败不影响整体
|
||||
*/
|
||||
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
|
||||
if (!ids.length) return []
|
||||
// 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求
|
||||
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
if (!validIds.length) return []
|
||||
|
||||
try {
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
|
||||
validIds.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
|
||||
)
|
||||
return results
|
||||
.filter(
|
||||
@@ -57,7 +59,10 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!stableAssetIds.length || !enabled) {
|
||||
const validIds = stableAssetIds.filter(
|
||||
(id): id is string => typeof id === "string" && id.length > 0,
|
||||
)
|
||||
if (!validIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
@@ -68,7 +73,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(stableAssetIds)
|
||||
const result = await fetchAssetsByIds(validIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
|
||||
@@ -42,8 +42,9 @@ _mock_if_absent("celery", MagicMock())
|
||||
if "celery" in sys.modules and isinstance(sys.modules["celery"], MagicMock):
|
||||
sys.modules["celery"].Task = object
|
||||
|
||||
# Mock packages.shared.storage
|
||||
_mock_if_absent("packages.shared")
|
||||
# Mock packages.shared.storage(只 mock 目标子模块,不要 mock 父包
|
||||
# packages.shared——否则同进程后续从 packages.shared.* 导入任何子模块都会
|
||||
# 拿到 MagicMock,污染其他测试文件,例如 thumbnail_generator 的纯逻辑测试)
|
||||
_mock_if_absent("packages.shared.storage")
|
||||
|
||||
# Mock packages.adapters.sqlalchemy_impl.generated_video_repository
|
||||
|
||||
@@ -16,6 +16,14 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# 在 import worker_app 模块前 mock 掉数据库连接和 celery(同 test_ingest_validation.py)
|
||||
# 注意:模块级 sys.modules 注入若不撤销,会污染同一 pytest 进程(含 xdist
|
||||
# worker)后续收集/执行的其他测试文件——它们 from video_processing.xxx
|
||||
# import 会拿到 MagicMock(表现为 test_thumbnail_generator 纯逻辑用例
|
||||
# 断言到 <MagicMock>,50 个用例失败,且与 xdist 分发顺序相关)。
|
||||
# 因此在成功 import ingest_mod 之后立即恢复 sys.modules(同 test_dedup_pure.py
|
||||
# 的做法),mock 对象仍由本文件变量/ingest_mod 引用持有,不影响本文件测试。
|
||||
_SAVED_MODULES_KEYS = set(sys.modules.keys())
|
||||
|
||||
_mock_db_module = MagicMock()
|
||||
_mock_db_module.SessionLocal = MagicMock()
|
||||
sys.modules["worker_app.db"] = _mock_db_module
|
||||
@@ -44,6 +52,21 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
import pytest # noqa: E402
|
||||
from worker_app.tasks import ingest as ingest_mod # noqa: E402
|
||||
|
||||
# ── 立即恢复 sys.modules,避免 mock 泄漏到其他测试文件 ──
|
||||
# 本文件 patch.object 不依赖 mock 条目保留在 sys.modules:download_asset
|
||||
# 已被 ingest_mod 顶部 from import 绑定;thumb patcher 是字符串目标,start
|
||||
# 时会触发真实模块重新导入后再打补丁(真实模块 CI 可导入)。因此注入的
|
||||
# mock 条目必须全部删除——若保留 video_processing.* mock,同 xdist worker
|
||||
# 后续测试文件 import 仍会拿到 MagicMock(#1566 v1 曾因此漏修)。
|
||||
for _key in list(sys.modules.keys()):
|
||||
if _key not in _SAVED_MODULES_KEYS:
|
||||
del sys.modules[_key]
|
||||
del _SAVED_MODULES_KEYS
|
||||
|
||||
# 导入真实模块供本文件 patch.object 打补丁(restore 后 sys.modules 中已无
|
||||
# mock)。patch 在 stop 时会自动还原模块属性,不影响其他测试文件。
|
||||
from video_processing import oss_helpers as _oss_helpers_real # noqa: E402
|
||||
|
||||
|
||||
class _FakeJobRepo:
|
||||
def __init__(self, db):
|
||||
@@ -155,7 +178,7 @@ def task_env(tmp_path):
|
||||
"asset_repo": patch.object(ingest_mod, "SQLAlchemyAssetRepository", return_value=asset_repo),
|
||||
"download": patch.object(ingest_mod, "download_asset", return_value=True),
|
||||
"upload": patch.object(
|
||||
sys.modules["video_processing.oss_helpers"],
|
||||
_oss_helpers_real,
|
||||
"upload_to_oss",
|
||||
return_value=control["upload_url"],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user