video-flow-b/components/ui/shot-tab-content.tsx
2025-09-08 14:58:23 +08:00

655 lines
25 KiB
TypeScript

'use client';
import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { RefreshCw, User, Loader2, X, Download, Video, CircleX } from 'lucide-react';
import { cn } from '@/public/lib/utils';
import { PersonDetection, PersonDetectionScene } from './person-detection';
import { ShotsEditor } from './shot-editor/ShotsEditor';
import { CharacterLibrarySelector } from './character-library-selector';
import FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceCharacterPanel } from './replace-character-panel';
import HorizontalScroller from './HorizontalScroller';
import { useEditData } from '@/components/pages/work-flow/use-edit-data';
import { RoleEntity, VideoSegmentEntity } from '@/app/service/domain/Entities';
import { ShotVideo } from '@/api/DTO/movieEdit';
import { downloadVideo } from '@/utils/tools';
interface ShotTabContentProps {
currentSketchIndex: number;
originalVideos: ShotVideo[];
onApply: () => void;
onClose: () => void;
setActiveTab: (tabId: string) => void;
}
export const ShotTabContent = forwardRef<
{ switchBefore: (tabId: string) => boolean, saveOrCloseBefore: (type: 'apply' | 'close') => void },
ShotTabContentProps
>((props, ref) => {
const { currentSketchIndex = 0, onApply, onClose, originalVideos, setActiveTab } = props;
const {
loading,
shotData,
selectedSegment,
scriptRoles,
setSelectedSegment,
regenerateVideoSegment,
filterRole,
fetchUserRoleLibrary,
userRoleLibrary,
fetchRoleShots,
shotSelectionList,
applyRoleToSelectedShots,
calculateRecognitionBoxes,
setSelectedRole
} = useEditData('shot');
const [detections, setDetections] = useState<PersonDetection[]>([]);
const [scanState, setScanState] = useState<'idle' | 'scanning' | 'detected' | 'failed' | 'timeout'>('idle');
const [isScanFailed, setIsScanFailed] = useState(false);
const [isLoadingLibrary, setIsLoadingLibrary] = useState(false);
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false);
const [selectedCharacter, setSelectedCharacter] = useState<any>(null);
const [selectedLibaryRole, setSelectedLibaryRole] = useState<any>(null);
const [isLoadingShots, setIsLoadingShots] = useState(false);
const shotsEditorRef = useRef<any>(null);
const [isRegenerate, setIsRegenerate] = useState(false);
const [pendingRegeneration, setPendingRegeneration] = useState(false);
const [isInitialized, setIsInitialized] = useState(true);
const [triggerType, setTriggerType] = useState<'tab' | 'apply' | 'close'>('tab');
const [nextToTabId, setNextToTabId] = useState<string>('');
const [isRemindApplyUpdate, setIsRemindApplyUpdate] = useState(false);
const [updateData, setUpdateData] = useState<VideoSegmentEntity[]>([]);
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
useEffect(() => {
console.log('shotTabContent-----selectedSegment', selectedSegment);
}, [selectedSegment]);
useEffect(() => {
console.log('-==========shotTabContent===========-', shotData, currentSketchIndex, originalVideos);
// 只在初始化且有角色数据时执行
if (isInitialized && shotData.length > 0) {
setIsInitialized(false);
const defaultSelectIndex = currentSketchIndex >= shotData.length ? 0 : currentSketchIndex;
setSelectedSegment(shotData[defaultSelectIndex]);
}
}, [shotData, isInitialized]);
// 暴露方法给父组件
React.useImperativeHandle(ref, () => ({
switchBefore: (tabId: string) => {
setNextToTabId(tabId);
// 判断 是否修改数据
const isChange = handleGetUpdateData().length > 0;
console.log('switchBefore', isChange);
if (isChange) {
setTriggerType('tab');
setIsRemindApplyUpdate(true);
}
return isChange;
},
saveOrCloseBefore: (type: 'apply' | 'close') => {
console.log('saveOrCloseBefore');
// 判断 是否修改数据
const isChange = handleGetUpdateData().length > 0;
if (isChange) {
setTriggerType(type);
setIsRemindApplyUpdate(true);
} else {
onClose();
}
}
}));
useEffect(() => {
if (pendingRegeneration) {
console.log('pendingRegeneration', pendingRegeneration, selectedSegment?.lens);
regenerateVideoSegment().then(() => {
setPendingRegeneration(false);
setIsRegenerate(false);
});
}
}, [pendingRegeneration]);
// 监听当前选中segment变化
useEffect(() => {
console.log('shotTabContent-----shotData', shotData);
if (shotData.length > 0 && !selectedSegment) {
// 清空检测状态 和 检测结果
setScanState('idle');
setDetections([]);
}
}, [shotData, selectedSegment]);
// 获取修改的数据
const handleGetUpdateData = () => {
console.log('handleGetUpdateData', shotData, originalVideos);
const updateData: VideoSegmentEntity[] = [];
shotData.forEach((shot, index) => {
const a = shot.videoUrl.map((url) => url.video_url).join(',');
const b = originalVideos[index].urls?.join(',') || '';
if (a !== b) {
updateData.push({
...shot,
name: 'Segment ' + (index + 1)
});
}
});
console.log('updateData', updateData);
setUpdateData(updateData);
return updateData;
}
// 处理扫描开始
const handleScan = async () => {
if (scanState === 'detected') {
// 如果已经有检测结果,点击按钮退出检测状态
setScanState('idle');
setDetections([]); // 清除检测结果
return;
}
setScanState('scanning');
const containerElement = document.getElementById('person-detection-video') as HTMLVideoElement;
try {
const roleRecognitionResponse = await filterRole(containerElement);
console.log('roleRecognitionResponse', roleRecognitionResponse);
if (roleRecognitionResponse && roleRecognitionResponse.recognition_result.code === 200) {
const recognitionBoxes = calculateRecognitionBoxes(containerElement, roleRecognitionResponse.recognition_result.data);
console.log('recognitionBoxes', recognitionBoxes);
setDetections(recognitionBoxes.map((person: any) => ({
id: person.person_id,
name: person.person_id,
description: roleRecognitionResponse.characters_used.find((character: any) => character.character_name === person.person_id)?.character_description || '',
imageUrl: roleRecognitionResponse.characters_used.find((character: any) => character.character_name === person.person_id)?.avatar || '',
position: { top: person.top, left: person.left, width: person.width, height: person.height }
})));
} else {
setIsScanFailed(true);
}
setScanState('detected');
} catch (error) {
setIsScanFailed(true);
}
};
// 处理扫描超时
const handleScanTimeout = () => {
setIsScanFailed(true);
setScanState('timeout');
setDetections([]);
};
// 处理退出扫描
const handleScanExit = () => {
setScanState('idle');
setDetections([]);
};
// 处理检测到结果
const handleDetectionsChange = (newDetections: PersonDetection[]) => {
// console.log('handleDetectionsChange', newDetections);
// if (newDetections.length > 0 && scanState === 'scanning') {
// setScanState('detected');
// }
};
// 处理人物点击 打开角色库
const handlePersonClick = async (person: PersonDetection) => {
console.log('person', person);
const role: RoleEntity = {
id: person.id,
name: person.name,
generateText: person.description,
tags: [], // 由于 person 对象中没有标签信息,我们设置为空数组
imageUrl: person.imageUrl,
fromDraft: false, // 默认不是来自草稿箱
isChangeRole: true, // 默认没有发生角色形象的变更
};
console.log('role', role);
setSelectedRole(role);
setSelectedCharacter(person);
setIsLoadingLibrary(true);
setIsReplaceLibraryOpen(true);
await fetchUserRoleLibrary(role.generateText);
setIsLoadingLibrary(false);
};
// 从角色库中选择角色
const handleSelectCharacter = (index: number) => {
console.log('index', index);
setSelectedLibaryRole(userRoleLibrary[index]);
setIsReplaceLibraryOpen(false);
handleStartReplaceCharacter();
};
const handleStartReplaceCharacter = async () => {
setIsLoadingShots(true);
setIsReplacePanelOpen(true);
// 获取当前角色对应的视频片段
await fetchRoleShots(selectedCharacter?.name || '');
// 打开替换角色面板
setIsLoadingShots(false);
};
// 确认替换角色
const handleConfirmReplace = () => {
applyRoleToSelectedShots(selectedLibaryRole);
setIsReplacePanelOpen(false);
onApply();
};
// 应用修改
const handleApplyUpdate = () => {
console.log('apply update');
onApply();
}
// 忽略修改
const handleIgnoreUpdate = () => {
console.log('ignore update');
if (triggerType === 'apply') {
onClose();
} else if (triggerType === 'tab') {
setActiveTab(nextToTabId);
}
}
// 点击按钮重新生成
const handleRegenerate = async () => {
console.log('regenerate');
setIsRegenerate(true);
const shotInfo = shotsEditorRef.current.getShotInfo();
console.log('shotInfo', shotInfo);
if (selectedSegment) {
setSelectedSegment({
...selectedSegment,
lens: shotInfo
});
}
setTimeout(() => {
setPendingRegeneration(true);
}, 1000);
};
// 新增分镜
const handleAddShot = () => {
console.log('add shot');
shotsEditorRef.current.addShot();
};
// 切换选择分镜
const handleSelectShot = (index: number) => {
// 通过 video_id 找到对应的分镜
const selectedVideo = originalVideos[index];
const targetSegment = shotData.find(shot =>
shot.id === selectedVideo.video_id
);
if (targetSegment) {
setSelectedSegment(targetSegment);
}
};
// 如果没有数据,显示空状态
if (originalVideos.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<Video className="w-16 h-16 mb-4" />
<p>No video data</p>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{/* 上部分 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{/* 分镜缩略图行 */}
<div className="relative">
<HorizontalScroller
itemWidth={128}
gap={0}
selectedIndex={originalVideos.findIndex(shot => selectedSegment?.id === shot.video_id)}
onItemClick={(i: number) => handleSelectShot(i)}
>
{originalVideos.map((shot, index) => (
<motion.div
key={shot.video_id || index}
className={cn(
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
selectedSegment?.id === shot.video_id ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{/** 进行中 显示加载中 */}
{shot.video_status === 0 && (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
</div>
)}
{/** 失败 显示失败 */}
{shot.video_status === 2 && (
<div className="w-full h-full flex items-center justify-center bg-red-500/10">
<CircleX className="w-4 h-4 text-red-500/80" />
</div>
)}
{/** 成功 显示视频 */}
{shot.video_status === 1 && shot.urls && shot.urls.length > 0 && (
<video
src={shot.urls[0]}
key={shot.urls[0]}
className="w-full h-full object-cover"
muted
loop
playsInline
preload="none"
poster={`${shot.urls[0]}?x-oss-process=video/snapshot,t_1000,f_jpg`}
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => e.currentTarget.pause()}
/>
)}
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90">{index + 1}</span>
</div>
{/* 鼠标悬浮/移出 显示/隐藏 删除图标 */}
{/* <div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<button
onClick={() => console.log('Delete sketch')}
className="text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div> */}
</motion.div>
))}
</HorizontalScroller>
</div>
{/* 视频描述行 - 单行滚动 */}
<div className="relative group">
<HorizontalScroller
itemWidth={'auto'}
gap={0}
selectedIndex={originalVideos.findIndex(shot => selectedSegment?.id === shot.video_id)}
onItemClick={(i: number) => handleSelectShot(i)}
>
{originalVideos.map((shot, index) => {
const isActive = selectedSegment?.id === shot.video_id;
return (
<motion.div
key={shot.video_id || index}
className={cn(
'flex-shrink-0 cursor-pointer transition-all duration-300',
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
)}
initial={false}
animate={{
scale: isActive ? 1.02 : 1,
}}
>
<div className="flex items-center gap-2">
<span className="text-sm whitespace-nowrap flex items-center gap-1">
<span>Segment {index + 1}</span>
</span>
{index < originalVideos.length - 1 && (
<span className="text-white/20">|</span>
)}
</div>
</motion.div>
);
})}
</HorizontalScroller>
{/* 渐变遮罩 */}
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-[#1a1b1e] to-transparent pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</motion.div>
{/* 下部分 */}
{loading ? (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<div className="w-12 h-12 mb-4 animate-spin rounded-full border-b-2 border-blue-600" />
<p>Loading...</p>
</div>
) : selectedSegment && (
<motion.div
className="grid grid-cols-2 gap-4 w-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
key={selectedSegment?.id}
>
{/* 视频预览和操作 */}
<div className="space-y-4 col-span-1">
{/* 选中的视频预览 */}
<>
{(selectedSegment?.status === 0) && (
<div className="w-full h-full flex items-center gap-1 justify-center rounded-lg bg-black/30">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
<span className="text-white/50">Loading...</span>
</div>
)}
<AnimatePresence mode="wait">
{selectedSegment?.status === 1 && selectedSegment?.videoUrl.length && (
<motion.div
className="aspect-video rounded-lg overflow-hidden relative group"
key={`video-preview-${selectedSegment?.id}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<PersonDetectionScene
videoSrc={selectedSegment?.videoUrl[0].video_url}
detections={detections}
scanState={scanState}
triggerScan={scanState === 'scanning'}
triggerSuccess={scanState === 'detected'}
onScanTimeout={handleScanTimeout}
onScanExit={handleScanExit}
onDetectionsChange={handleDetectionsChange}
onPersonClick={handlePersonClick}
/>
<motion.div className='absolute top-4 right-4 flex gap-2'>
{/* 人物替换按钮 */}
<motion.button
onClick={() => {
window.msg.error('No permission!');
return;
handleScan()
}}
className={`p-2 backdrop-blur-sm transition-colors z-10 rounded-full
${scanState === 'detected'
? 'bg-cyan-500/50 hover:bg-cyan-500/70 text-white'
: 'bg-black/50 hover:bg-black/70 text-white'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{scanState === 'scanning' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : scanState === 'detected' ? (
<X className="w-4 h-4" />
) : (
<User className="w-4 h-4" />
)}
</motion.button>
{/** 下载视频按钮 */}
<motion.button
onClick={() => {
setIsLoadingDownloadBtn(true);
downloadVideo(selectedSegment?.videoUrl[0].video_url);
setIsLoadingDownloadBtn(false);
}}
className="p-2 backdrop-blur-sm transition-colors z-10 rounded-full bg-black/50 hover:bg-black/70 text-white disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isLoadingDownloadBtn}
>
{isLoadingDownloadBtn ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</motion.button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{(selectedSegment?.status === 2) && (
<div className="w-full h-full flex gap-1 items-center justify-center rounded-lg bg-red-500/10">
<CircleX className="w-4 h-4 text-red-500" />
<span className="text-white/50">Failed, click to regenerate</span>
</div>
)}
</>
</div>
{/* 基础配置 */}
<div className='space-y-4 col-span-1' key={selectedSegment?.id}>
<ShotsEditor
ref={shotsEditorRef}
roles={scriptRoles}
shotInfo={selectedSegment.lens}
style={{height: 'calc(100% - 4rem)'}}
/>
{/* 重新生成按钮、新增分镜按钮 */}
<div className="grid grid-cols-1 gap-2">
{/* <motion.button
onClick={() => handleAddShot()}
className="flex items-center justify-center gap-2 px-4 py-3 bg-pink-500/10 hover:bg-pink-500/20
text-pink-500 rounded-lg transition-colors"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Plus className="w-4 h-4" />
<span>Add Shot</span>
</motion.button> */}
<motion.button
onClick={() => {
handleRegenerate();
}}
className="flex items-center justify-center gap-2 px-4 py-3 bg-blue-500/10 hover:bg-blue-500/20
text-blue-500 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={isRegenerate}
>
<RefreshCw className="w-4 h-4" />
<span>{isRegenerate ? 'Regenerating...' : 'Regenerate'}</span>
</motion.button>
</div>
</div>
</motion.div>
)}
<FloatingGlassPanel
open={isReplacePanelOpen}
width='66vw'
onClose={() => setIsReplacePanelOpen(false)}
>
<ReplaceCharacterPanel
isLoading={isLoadingShots}
shots={shotSelectionList}
role={selectedLibaryRole}
showAddToLibrary={false}
onClose={() => setIsReplacePanelOpen(false)}
onConfirm={handleConfirmReplace}
/>
</FloatingGlassPanel>
<CharacterLibrarySelector
isLoading={isLoadingLibrary}
userRoleLibrary={userRoleLibrary}
isReplaceLibraryOpen={isReplaceLibraryOpen}
setIsReplaceLibraryOpen={setIsReplaceLibraryOpen}
onSelect={handleSelectCharacter}
/>
<FloatingGlassPanel
open={isRemindApplyUpdate}
width='66vw'
onClose={() => setIsRemindApplyUpdate(false)}
>
<div className='h-full w-full flex flex-col'>
{/* 提示文字 有分镜被修改 是否需要应用 */}
<div className='flex-1 flex items-center justify-center'>
<span className='text-white/50'>Three are some segments have been modified, do you need to apply?</span>
</div>
{/* 已修改分镜列表 */}
<HorizontalScroller
itemWidth={128}
gap={0}
>
{updateData.map((shot, index) => (
<motion.div
key={shot.id || index}
className={cn(
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group'
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{(shot.status === 0 || shot.videoUrl.length === 0) && (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
</div>
)}
{shot.status === 1 && shot.videoUrl[0] && (
<video
src={shot.videoUrl[0].video_url}
key={shot.videoUrl[0].video_url}
className="w-full h-full object-cover"
muted
loop
playsInline
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => e.currentTarget.pause()}
/>
)}
{/* 任务失败 */}
{shot.status === 2 && (
<div className="w-full h-full flex items-center justify-center bg-red-500/10">
<CircleX className="w-4 h-4 text-red-500" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/60 to-transparent">
<span className="text-xs text-white/90">{shot.name}</span>
</div>
</motion.div>
))}
</HorizontalScroller>
{/* 按钮 应用 忽略 */}
<div className='flex items-center justify-end gap-2'>
<motion.button
onClick={() => handleApplyUpdate()}
className='px-4 py-2 bg-blue-500/10 hover:bg-blue-500/20
text-blue-500 rounded-lg transition-colors'
>
<span>Apply</span>
</motion.button>
<motion.button
onClick={() => handleIgnoreUpdate()}
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-500/20
text-gray-500 rounded-lg transition-colors'
>
<span>Ignore</span>
</motion.button>
</div>
</div>
</FloatingGlassPanel>
</div>
);
});
ShotTabContent.displayName = 'ShotTabContent';