feat: 空镜素材自动匹配改为手动匹配

- 移除页面加载时的自动批量匹配逻辑
- 每个未匹配空镜卡片新增'匹配素材'按钮
- 点击后触发批量匹配,已匹配后显示'换一个'按钮
This commit is contained in:
小鱼开发
2026-05-21 21:19:25 +08:00
parent 2cece72abe
commit 54fc6b2638
3 changed files with 67 additions and 60 deletions
@@ -17,6 +17,7 @@ interface ShotTimelineProps {
onSceneClick: (id: number, shot: ScriptShot) => void;
onSwitchMaterial: (shotId: string) => void;
onUploadMaterial: (shotId: string) => void;
onMatchMaterial: (shotId: string) => void;
}
/**
@@ -35,6 +36,7 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
onSceneClick,
onSwitchMaterial,
onUploadMaterial,
onMatchMaterial,
}) => {
return (
<div
@@ -128,7 +130,19 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
</button>
</>
) : (
<> </>
<>
<span> </span>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: '11px', padding: '2px 6px', color: 'var(--primary)' }}
onClick={(e) => {
e.stopPropagation();
onMatchMaterial(String(shot.id));
}}
>
</button>
</>
)}
<button
className="btn btn-ghost btn-sm"
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { batchMatch, matchMaterial } from '../../../api/modules/materials';
import { toast } from '../../../store/uiStore';
@@ -9,6 +9,7 @@ import type { ScriptShot } from '../../../types/project';
export interface UseEmptyShotMaterialsResult {
materialMatchMap: Record<string, { url: string; duration: number } | null>;
userUploadedMaterials: Record<string, { path: string; duration: number }>;
matchMaterials: () => Promise<void>;
switchMaterial: (shotId: string) => Promise<{ url: string; duration: number } | null>;
uploadMaterial: (shotId: string) => Promise<{ path: string; duration: number } | null>;
setMaterialMatchMap: React.Dispatch<
@@ -21,7 +22,7 @@ export interface UseEmptyShotMaterialsResult {
/**
* 空镜素材管理 Hook
* - 动匹配空镜素材(调用后端批量接口)
* - 动匹配空镜素材(调用后端批量接口)
* - 支持手动切换素材
* - 支持上传本地素材
*/
@@ -36,77 +37,61 @@ export function useEmptyShotMaterials(
Record<string, { path: string; duration: number }>
>({});
// 自动匹配空镜素材(调用后端接口)
useEffect(() => {
/**
* 手动匹配空镜素材(调用后端批量接口)
*/
const matchMaterials = async () => {
const emptyShots = shots.filter((s) => s.type === 'empty_shot' && s.scene);
if (emptyShots.length === 0) {
setMaterialMatchMap({});
return;
}
let canceled = false;
const newMap: Record<string, { url: string; duration: number } | null> = {};
const assignedMap = computeAssignedIntervals(shots);
async function doMatch() {
const newMap: Record<string, { url: string; duration: number } | null> = {};
const assignedMap = computeAssignedIntervals(shots);
// 收集需要调用 API 匹配的 shots(排除用户上传和已有空镜的)
const apiScenes: Array<{ scene: string; duration: number }> = [];
const apiShotIds: string[] = [];
// 收集需要调用 API 匹配的 shots(排除用户上传和已有空镜的)
const apiScenes: Array<{ scene: string; duration: number }> = [];
const apiShotIds: string[] = [];
for (const shot of emptyShots) {
if (canceled) {
break;
}
const sid = String(shot.id);
// 优先使用用户上传的素材
const userMaterial = userUploadedMaterials[sid];
if (userMaterial) {
newMap[sid] = { url: userMaterial.path, duration: userMaterial.duration };
continue;
}
// 已有匹配结果(来自上次视频生成后的保存),直接复用
if (shot.emptyShotMaterial) {
newMap[sid] = shot.emptyShotMaterial;
continue;
}
const assigned = assignedMap[shot.id];
const requiredDuration = (assigned.assignedEnd - assigned.assignedStart) / 1000;
apiScenes.push({ scene: shot.scene || '', duration: requiredDuration });
apiShotIds.push(sid);
for (const shot of emptyShots) {
const sid = String(shot.id);
// 优先保留用户上传的素材
const userMaterial = userUploadedMaterials[sid];
if (userMaterial) {
newMap[sid] = { url: userMaterial.path, duration: userMaterial.duration };
continue;
}
// 批量匹配(后端通过 Redis 自动做项目级去重)
if (apiScenes.length > 0 && !canceled) {
try {
const batchResult = await batchMatch(apiScenes, projectId || undefined);
for (let i = 0; i < apiShotIds.length; i++) {
newMap[apiShotIds[i]] = batchResult.results[i] ?? null;
}
} catch (err) {
console.error('[VideoGeneration] 批量素材匹配失败:', err);
toast.error(err instanceof Error ? err.message : '素材匹配失败');
for (const sid of apiShotIds) {
newMap[sid] = null;
}
}
// 已有匹配结果(来自上次视频生成后的保存),直接复用
if (shot.emptyShotMaterial) {
newMap[sid] = shot.emptyShotMaterial;
continue;
}
const assigned = assignedMap[shot.id];
const requiredDuration = (assigned.assignedEnd - assigned.assignedStart) / 1000;
apiScenes.push({ scene: shot.scene || '', duration: requiredDuration });
apiShotIds.push(sid);
}
if (!canceled) {
setMaterialMatchMap(newMap);
// 匹配结果仅作为组件本地状态,视频生成成功后再统一持久化到 segments.json
// 批量匹配(后端通过 Redis 自动做项目级去重)
if (apiScenes.length > 0) {
try {
const batchResult = await batchMatch(apiScenes, projectId || undefined);
for (let i = 0; i < apiShotIds.length; i++) {
newMap[apiShotIds[i]] = batchResult.results[i] ?? null;
}
} catch (err) {
console.error('[VideoGeneration] 批量素材匹配失败:', err);
toast.error(err instanceof Error ? err.message : '素材匹配失败');
for (const sid of apiShotIds) {
newMap[sid] = null;
}
}
}
doMatch().catch((err) => {
console.error('[VideoGeneration] 素材匹配失败:', err);
toast.error(err instanceof Error ? err.message : '素材匹配失败');
});
return () => {
canceled = true;
};
}, [shots, userUploadedMaterials, projectId]);
setMaterialMatchMap(newMap);
// 匹配结果仅作为组件本地状态,视频生成成功后再统一持久化到 segments.json
};
/**
* 切换空镜素材(换一个)
@@ -201,6 +186,7 @@ export function useEmptyShotMaterials(
return {
materialMatchMap,
userUploadedMaterials,
matchMaterials,
switchMaterial,
uploadMaterial,
setMaterialMatchMap,
@@ -143,6 +143,7 @@ export default function VideoGeneration() {
const {
materialMatchMap,
userUploadedMaterials,
matchMaterials,
switchMaterial,
uploadMaterial,
setMaterialMatchMap,
@@ -319,6 +320,11 @@ export default function VideoGeneration() {
await uploadMaterial(shotId);
};
// 处理素材匹配(手动触发批量匹配)
const handleMatchMaterial = async (_shotId: string) => {
await matchMaterials();
};
const activeShot = shots.find((s) => Number(s.id) === activeScene);
return (
@@ -343,6 +349,7 @@ export default function VideoGeneration() {
onSceneClick={handleSceneClick}
onSwitchMaterial={handleSwitchMaterial}
onUploadMaterial={handleUploadMaterial}
onMatchMaterial={handleMatchMaterial}
/>
<GenerationControls