feat: 空镜素材自动匹配改为手动匹配
- 移除页面加载时的自动批量匹配逻辑 - 每个未匹配空镜卡片新增'匹配素材'按钮 - 点击后触发批量匹配,已匹配后显示'换一个'按钮
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user