更新人脸识别接口的返回类型为 RoleRecognitionResponse,移除未使用的 SceneService 代码,优化 ShotService 中的 filterRole 方法,调整 ShotEditUsecase 中的日志输出,增加角色识别相关的 TypeScript 类型定义。

This commit is contained in:
海龙 2025-08-12 15:02:14 +08:00
parent 99ea9a2c0a
commit 5f6a25f9de
7 changed files with 173 additions and 483 deletions

View File

@ -17,6 +17,7 @@ import {
import { task_item, VideoSegmentEntityAdapter } from "@/app/service/adapter/oldErrAdapter";
import { VideoFlowProjectResponse, NewCharacterItem, NewCharacterListResponse, CharacterListByProjectWithHighlightResponse, CharacterUpdateAndRegenerateRequest, CharacterUpdateAndRegenerateResponse } from "./allMovieType";
import { RoleResponse } from "@/app/service/usecase/RoleEditUseCase";
import { RoleRecognitionResponse } from "@/app/service/usecase/ShotEditUsecase";
// API 响应类型
interface BaseApiResponse<T> {
@ -820,7 +821,7 @@ export const updateShotPrompt = async (request: {
/**
*
* @param request -
* @returns Promise<ApiResponse<any>>
* @returns Promise<ApiResponse<RoleRecognitionResponse>>
*/
export const faceRecognition = async (request: {
/** 项目ID */
@ -829,8 +830,8 @@ export const faceRecognition = async (request: {
video_id: string;
/** 目标图片URL */
target_image_url: string;
}): Promise<ApiResponse<any>> => {
return post<ApiResponse<any>>("/character/face_recognition", request);
}): Promise<ApiResponse<RoleRecognitionResponse>> => {
return post("/character/face_recognition", request);
};
/**

View File

@ -1,427 +0,0 @@
import { useState, useCallback, useMemo } from 'react';
import { SceneEntity, AITextEntity, VideoSegmentEntity } from '../domain/Entities';
import { SceneItem, TagItem, TextItem } from '../domain/Item';
import { SceneEditUseCase } from '../usecase/SceneEditUseCase';
import { TagEditUseCase } from '../usecase/TagEditUseCase';
import { getSceneShots, getSceneData, getSceneList } from '@/api/video_flow';
/**
*
*/
interface ShotSelectionItem {
/** 分镜ID */
id: string;
/** 分镜名称 */
name: string;
/** 是否已选中 */
selected: boolean;
/** 是否已应用场景 */
applied: boolean;
/** 分镜数据 */
shot: VideoSegmentEntity;
}
/**
* Hook返回值接口
*/
interface UseSceneService {
// 响应式数据
/** 场景列表 */
sceneList: SceneItem[];
/** 当前选中的场景 */
selectedScene: SceneItem | null;
// /** 当前场景的AI文本 */
// currentSceneText: TextItem | null; // TODO: TextEditUseCase not implemented yet
/** 当前场景的标签列表 */
currentSceneTags: TagItem[];
/** 场景图片URL */
sceneImageUrl: string;
/** 分镜选择列表 */
shotSelectionList: ShotSelectionItem[];
/** 是否全选分镜 */
isAllShotsSelected: boolean;
/** 已选中的分镜数量 */
selectedShotsCount: number;
// 操作方法
/** 获取场景列表 */
fetchSceneList: (projectId: string) => Promise<void>;
/** 选择场景 */
selectScene: (sceneId: string) => void;
/** 初始化当前选中场景的AI文本和标签数据 */
initializeSceneData: () => Promise<void>;
// /** 优化AI文本 */
// optimizeSceneText: () => Promise<void>; // TODO: TextEditUseCase not implemented yet
// /** 修改AI文本 */
// updateSceneText: (newContent: string) => Promise<void>; // TODO: TextEditUseCase not implemented yet
/** 修改标签内容 */
updateTagContent: (tagId: string, newContent: string | number) => Promise<void>;
/** 重新生成场景 */
regenerateScene: () => Promise<void>;
/** 获取场景出现的分镜列表 */
fetchSceneShots: () => Promise<void>;
/** 切换全选与全不选 */
toggleSelectAllShots: () => void;
/** 选择/取消选择单个分镜 */
toggleShotSelection: (shotId: string) => void;
/** 应用场景到选中的分镜 */
applySceneToSelectedShots: () => Promise<void>;
}
/**
* Hook
*
*/
export const useSceneServiceHook = (): UseSceneService => {
// 响应式状态
const [sceneList, setSceneList] = useState<SceneItem[]>([]);
const [selectedScene, setSelectedScene] = useState<SceneItem | null>(null);
// const [currentSceneText, setCurrentSceneText] = useState<TextItem | null>(null); // TODO: TextEditUseCase not implemented yet
const [currentSceneTags, setCurrentSceneTags] = useState<TagItem[]>([]);
const [shotSelectionList, setShotSelectionList] = useState<ShotSelectionItem[]>([]);
// UseCase实例 - 在场景选择时初始化
const [sceneEditUseCase, setSceneEditUseCase] = useState<SceneEditUseCase | null>(null);
// const [textEditUseCase, setTextEditUseCase] = useState<TextEditUseCase | null>(null); // TODO: TextEditUseCase not implemented yet
const [tagEditUseCases, setTagEditUseCases] = useState<Map<string, TagEditUseCase>>(new Map());
// 计算属性
/**
* URL
* @description URL
*/
const sceneImageUrl = useMemo(() => {
return selectedScene?.entity.imageUrl || '';
}, [selectedScene]);
/**
*
* @description
*/
const isAllShotsSelected = useMemo(() => {
return shotSelectionList.length > 0 && shotSelectionList.every(shot => shot.selected);
}, [shotSelectionList]);
/**
*
* @description
*/
const selectedShotsCount = useMemo(() => {
return shotSelectionList.filter(shot => shot.selected).length;
}, [shotSelectionList]);
/**
*
* @description ID获取所有场景列表
* @param projectId ID
* @throws {Error} API调用失败时抛出错误
* @returns {Promise<void>} Promise
*/
const fetchSceneList = useCallback(async (projectId: string) => {
try {
const response = await getSceneList({
projectId: projectId
});
if (response.successful) {
const sceneItems = response.data.map(scene => new SceneItem(scene));
setSceneList(sceneItems);
} else {
throw new Error(`获取场景列表失败: ${response.message}`);
}
} catch (error) {
console.error('获取场景列表失败:', error);
throw error;
}
}, []);
/**
*
* @description ID选择场景UseCase实例
* @param sceneId ID
*/
const selectScene = useCallback(async (sceneId: string) => {
const scene = sceneList.find(s => s.entity.id === sceneId);
if (scene) {
setSelectedScene(scene);
// 初始化场景编辑UseCase实例
setSceneEditUseCase(new SceneEditUseCase(scene));
// 清空文本和标签相关状态
setTextEditUseCase(null);
setTagEditUseCases(new Map());
setCurrentSceneText(null);
setCurrentSceneTags([]);
await initializeSceneData();
}
}, [sceneList]);
/**
*
* @description AI文本和标签数据
* @throws {Error} API调用失败时抛出错误
* @returns {Promise<void>} Promise
*/
const initializeSceneData = useCallback(async () => {
if (!selectedScene) {
throw new Error('请先选择场景');
}
try {
const response = await getSceneData({
sceneId: selectedScene.entity.id
});
if (response.successful) {
const { text, tags } = response.data;
const textItem = new TextItem(text);
const tagItems = tags.map(tag => new TagItem(tag));
// 设置当前场景的AI文本和标签
setCurrentSceneText(textItem);
setCurrentSceneTags(tagItems);
// 初始化文本UseCase
setTextEditUseCase(new TextEditUseCase(textItem));
// 初始化标签UseCase
const newTagEditUseCases = new Map<string, TagEditUseCase>();
tagItems.forEach(tag => {
newTagEditUseCases.set(tag.entity.id, new TagEditUseCase(tag));
});
setTagEditUseCases(newTagEditUseCases);
} else {
throw new Error(`获取场景数据失败: ${response.message}`);
}
} catch (error) {
console.error('初始化场景数据失败:', error);
throw error;
}
}, [selectedScene]);
/**
* AI文本
* @description AI文本进行优化
* @throws {Error} UseCase未初始化时抛出错误
* @returns {Promise<void>} Promise
*/
const optimizeSceneText = useCallback(async () => {
if (!textEditUseCase) {
throw new Error('文本编辑UseCase未初始化');
}
if (!currentSceneText || !currentSceneText.entity.content) {
throw new Error('没有可优化的文本内容');
}
const optimizedContent = await textEditUseCase.getOptimizedContent();
const updatedTextItem = await textEditUseCase.updateText(optimizedContent);
setCurrentSceneText(updatedTextItem);
}, [textEditUseCase, currentSceneText]);
/**
* AI文本
* @description AI文本内容
* @param newContent
* @throws {Error} UseCase未初始化时抛出错误
* @returns {Promise<void>} Promise
*/
const updateSceneText = useCallback(async (newContent: string) => {
if (!textEditUseCase) {
throw new Error('文本编辑UseCase未初始化');
}
if (!currentSceneText) {
throw new Error('没有可编辑的文本');
}
const updatedTextItem = await textEditUseCase.updateText(newContent);
setCurrentSceneText(updatedTextItem);
}, [textEditUseCase, currentSceneText]);
/**
*
* @description
* @param tagId ID
* @param newContent
* @throws {Error} UseCase未初始化时抛出错误
* @returns {Promise<void>} Promise
*/
const updateTagContent = useCallback(async (tagId: string, newContent: string | number) => {
const tagEditUseCase = tagEditUseCases.get(tagId);
if (!tagEditUseCase) {
throw new Error(`标签编辑UseCase未初始化标签ID: ${tagId}`);
}
const updatedTagItem = await tagEditUseCase.updateTag(newContent);
setCurrentSceneTags(prev =>
prev.map(tag =>
tag.entity.id === tagId
? updatedTagItem
: tag
)
);
}, [tagEditUseCases]);
/**
*
* @description 使AI文本和标签重新生成场景
* @throws {Error} UseCase未初始化时抛出错误
* @returns {Promise<void>} Promise
*/
const regenerateScene = useCallback(async () => {
if (!sceneEditUseCase) {
throw new Error('场景编辑UseCase未初始化');
}
if (!selectedScene || !currentSceneText || currentSceneTags.length === 0) {
throw new Error('缺少重新生成场景所需的数据');
}
const newSceneEntity = await sceneEditUseCase.AIgenerateScene(currentSceneText, currentSceneTags);
const newSceneItem = new SceneItem(newSceneEntity);
setSelectedScene(newSceneItem);
setSceneList(prev =>
prev.map(scene =>
scene.entity.id === newSceneEntity.id ? newSceneItem : scene
)
);
}, [sceneEditUseCase, selectedScene, currentSceneText, currentSceneTags]);
/**
*
* @description
* @throws {Error} API调用失败时抛出错误
* @returns {Promise<void>} Promise
*/
const fetchSceneShots = useCallback(async () => {
if (!selectedScene) {
throw new Error('请先选择场景');
}
try {
const response = await getSceneShots({
sceneId: selectedScene.entity.id
});
if (response.successful) {
const { shots, appliedShotIds } = response.data;
const shotSelectionItems: ShotSelectionItem[] = shots.map(shot => ({
id: shot.id,
name: shot.name,
selected: false,
applied: appliedShotIds.includes(shot.id),
shot
}));
setShotSelectionList(shotSelectionItems);
} else {
throw new Error(`获取场景分镜列表失败: ${response.message}`);
}
} catch (error) {
console.error('获取场景分镜列表失败:', error);
throw error;
}
}, [selectedScene]);
/**
*
* @description
*/
const toggleSelectAllShots = useCallback(() => {
setShotSelectionList(prev => {
const isAllSelected = prev.length > 0 && prev.every(shot => shot.selected);
return prev.map(shot => ({ ...shot, selected: !isAllSelected }));
});
}, []);
/**
* /
* @description
* @param shotId ID
*/
const toggleShotSelection = useCallback((shotId: string) => {
setShotSelectionList(prev =>
prev.map(shot =>
shot.id === shotId
? { ...shot, selected: !shot.selected }
: shot
)
);
}, []);
/**
*
* @description
* @throws {Error} UseCase未初始化时抛出错误
* @returns {Promise<void>} Promise
*/
const applySceneToSelectedShots = useCallback(async () => {
if (!sceneEditUseCase) {
throw new Error('场景编辑UseCase未初始化');
}
if (!selectedScene) {
throw new Error('请先选择场景');
}
const selectedShotIds = shotSelectionList
.filter(shot => shot.selected)
.map(shot => shot.id);
if (selectedShotIds.length === 0) {
throw new Error('请先选择要应用的分镜');
}
await sceneEditUseCase.applyScene(selectedShotIds);
setShotSelectionList(prev =>
prev.map(shot =>
selectedShotIds.includes(shot.id)
? { ...shot, applied: true, selected: false }
: shot
)
);
}, [sceneEditUseCase, selectedScene, shotSelectionList]);
return {
// 响应式数据
sceneList,
selectedScene,
currentSceneText,
currentSceneTags,
sceneImageUrl,
shotSelectionList,
isAllShotsSelected,
selectedShotsCount,
// 操作方法
/** 获取场景列表 */
fetchSceneList,
/** 选择场景 */
selectScene,
/** 初始化当前选中场景的AI文本和标签数据 */
initializeSceneData,
/** 优化AI文本 */
optimizeSceneText,
/** 修改AI文本 */
updateSceneText,
/** 修改标签内容 */
updateTagContent,
/** 重新生成场景 */
regenerateScene,
/** 获取场景出现的分镜列表 */
fetchSceneShots,
/** 切换全选与全不选 */
toggleSelectAllShots,
/** 选择/取消选择单个分镜 */
toggleShotSelection,
/** 应用场景到选中的分镜 */
applySceneToSelectedShots
};
};

View File

@ -40,7 +40,7 @@ export interface UseShotService {
/** 删除指定镜头 */
deleteLens: (lensName: string) => void;
/** 获取视频当前帧并上传到七牛云 */
filterRole: (video: HTMLVideoElement, projectId: string, videoId: string) => Promise<string>;
filterRole: (video: HTMLVideoElement) => Promise<string>;
/** 设置角色简单数据 */
setSimpleCharacter: (characters: SimpleCharacter[]) => void;
}
@ -75,8 +75,6 @@ export const useShotService = (): UseShotService => {
const segments = await vidoEditUseCase.getVideoSegmentList(projectId);
setProjectId(projectId);
console.log('segments', segments);
setVideoSegments(segments);
setIntervalIdHandler();
} catch (error) {
@ -97,6 +95,9 @@ export const useShotService = (): UseShotService => {
// 定义定时任务每5秒执行一次
const newIntervalId = setInterval(async () => {
try {
if (!projectId) {
return;
}
const segments = await vidoEditUseCase.getVideoSegmentList(projectId);
setVideoSegments(prevSegments => {
@ -345,7 +346,7 @@ export const useShotService = (): UseShotService => {
// 上传到七牛云
const imageUrl = await uploadToQiniu(file, token);
console.log('imageUrl', imageUrl);
// 调用用例中的识别角色方法
if (vidoEditUseCase) {
try {

View File

@ -70,34 +70,6 @@ export class TextItem extends EditItem<AITextEntity> {
}
}
/**
*
*/
export class RoleItem extends EditItem<RoleEntity> {
type: ItemType.IMAGE = ItemType.IMAGE;
constructor(
entity: RoleEntity,
metadata: Record<string, any> = {}
) {
super(entity, metadata);
}
}
/**
*
*/
export class TagItem extends EditItem<TagValueObject> {
type: ItemType.TEXT = ItemType.TEXT;
constructor(
entity: TagValueObject,
metadata: Record<string, any> = {}
) {
super(entity, metadata);
}
}
/**
*
*/

View File

@ -56,16 +56,24 @@ export class VideoSegmentEditUseCase {
* @returns VideoSegmentEntity[]
* @throws Error video_id时
*/
/**
* @description video_id
* @param segments
* @param detail
* @returns VideoSegmentEntity[]
* @throws Error video_id时
*/
private matchVideoSegmentsWithIds(
segments: VideoSegmentEntity[],
detail: VideoFlowProjectResponse
): VideoSegmentEntity[] {
console.log('[matchVideoSegmentsWithIds] 输入segments:', segments);
console.log('[matchVideoSegmentsWithIds] 输入detail:', detail);
const projectData = detail.data as any;
const videoData = projectData?.data?.video?.data;
const videoData = detail?.data?.video?.data;
if (!videoData || !Array.isArray(videoData)) {
console.log('[matchVideoSegmentsWithIds] videoData无效直接返回原segments');
return segments;
}
@ -78,6 +86,7 @@ export class VideoSegmentEditUseCase {
});
}
});
console.log('[matchVideoSegmentsWithIds] urlToVideoMap keys:', Array.from(urlToVideoMap.keys()));
// 为每个视频片段匹配video_id并重新创建实体
const updatedSegments: VideoSegmentEntity[] = [];
@ -95,12 +104,15 @@ export class VideoSegmentEditUseCase {
id: videoItem.video_id
};
updatedSegments.push(updatedSegment);
console.log(`[matchVideoSegmentsWithIds] segment "${segment.name}" 匹配到 video_id:`, videoItem.video_id);
} else {
// 如果没有匹配到,保持原样
updatedSegments.push(segment);
console.log(`[matchVideoSegmentsWithIds] segment "${segment.name}" 未匹配到video_id, 保持原样`);
}
} else {
updatedSegments.push(segment);
console.log(`[matchVideoSegmentsWithIds] segment "${segment.name}" videoUrl无效, 保持原样`);
}
});
@ -110,10 +122,12 @@ export class VideoSegmentEditUseCase {
);
if (unmatchedSegments.length > 0) {
console.warn('以下视频片段未匹配到video_id:', unmatchedSegments.map(s => ({ name: s.name, videoUrl: s.videoUrl })));
throw new Error(`${unmatchedSegments.length} 个视频片段未匹配到对应的video_id`);
console.warn('[matchVideoSegmentsWithIds] 以下视频片段未匹配到video_id:', unmatchedSegments.map(s => ({ name: s.name, videoUrl: s.videoUrl })));
// throw new Error(`有 ${unmatchedSegments.length} 个视频片段未匹配到对应的video_id`);
}
console.log('[matchVideoSegmentsWithIds] 匹配后segments:', updatedSegments);
return updatedSegments;
}
@ -332,3 +346,131 @@ export class VideoSegmentEditUseCase {
}
}
}
/**
*
* @description
*/
export interface BoundingBox {
/** X坐标像素 */
x: number;
/** Y坐标像素 */
y: number;
/** 宽度(像素) */
width: number;
/** 高度(像素) */
height: number;
}
/**
*
* @description
*/
export interface MatchedPerson {
/** 人物ID */
person_id: string;
/** 边界框信息 */
bbox: BoundingBox;
/** 匹配置信度0-1之间 */
confidence: number;
}
/**
*
* @description AI识别图片中人物后的详细结果
*/
export interface RecognitionResultData {
/** 目标图片URL */
target_image_url: string;
/** 检测到的总人数 */
total_persons_detected: number;
/** 匹配的人物列表 */
matched_persons: MatchedPerson[];
}
/**
*
* @description API返回的角色识别完整响应
*/
export interface RecognitionResult {
/** 响应状态码 */
code: number;
/** 响应消息 */
message: string;
/** 识别结果数据 */
data: RecognitionResultData;
}
/**
* 使
* @description 使
*/
export interface CharacterUsed {
/** 角色名称 */
character_name: string;
/** 角色C-ID */
c_id: string;
/** 角色图片路径 */
image_path: string;
/** 角色头像 */
avatar: string;
}
/**
* API响应类型
* @description API响应结构
*/
export interface RoleRecognitionResponse {
/** 项目ID */
project_id: string;
/** 视频ID */
video_id: string;
/** 目标图片URL */
target_image_url: string;
/** 已知人物数量 */
known_persons_count: number;
/** 识别结果 */
recognition_result: RecognitionResult;
/** 使用的角色列表 */
characters_used: CharacterUsed[];
}
const roleRecognitionResponse:RoleRecognitionResponse = {
"project_id": "d0df7120-e27b-4f84-875c-e532f1bd318c",
"video_id": "984f3347-c81c-4af8-9145-49ead82becde",
"target_image_url": "https://cdn.qikongjian.com/videos/1754970412744_kqxplx.png",
"known_persons_count": 1,
"recognition_result": {
"code": 200,
"message": "识别完成",
"data":
{
"target_image_url": "https://cdn.qikongjian.com/videos/1754970412744_kqxplx.png",
"total_persons_detected": 1,
"matched_persons": [
{
"person_id": "CH-01",
"bbox": {
"x": 269,
"y": 23,
"width": 585,
"height": 685
},
"confidence": 0.36905956268310547
}
]
}
},
"characters_used": [
{
"character_name": "CH-01",
"c_id": "无C-ID",
"image_path": "无image_path",
"avatar": "https://cdn.huiying.video/template/Whisk_9afb196368.jpg"
}
]
}

View File

@ -54,10 +54,10 @@ type Props = {
onPersonClick?: (person: PersonDetection) => void;
};
export const PersonDetectionScene: React.FC<Props> = ({
backgroundImage,
videoSrc,
detections,
export const PersonDetectionScene: React.FC<Props> = ({
backgroundImage,
videoSrc,
detections,
triggerScan,
onScanStart,
onScanTimeout,
@ -192,6 +192,7 @@ export const PersonDetectionScene: React.FC<Props> = ({
className="absolute inset-0 w-full h-full object-cover z-0"
autoPlay
muted={false}
crossOrigin="anonymous"
loop
controls
/>
@ -245,7 +246,7 @@ export const PersonDetectionScene: React.FC<Props> = ({
) : (
<motion.div
initial={{ scale: 0.8 }}
animate={{
animate={{
scale: [0.8, 1.1, 1],
borderColor: ['rgb(239, 68, 68)', 'rgb(239, 68, 68)', 'rgb(239, 68, 68)']
}}
@ -375,7 +376,7 @@ export const PersonDetectionScene: React.FC<Props> = ({
{/* 人物识别框和浮签 */}
<AnimatePresence>
{detections.map((person, index) => {
return (
<div key={person.id} className="cursor-pointer" onClick={() => {
onPersonClick?.(person);

View File

@ -45,7 +45,7 @@ export function ShotTabContent({
if (shotData.length > 0) {
setSelectedSegment(shotData[selectedIndex]);
}
}, [selectedIndex]);
}, [selectedIndex, shotData]);
// 处理扫描开始
const handleScan = () => {
@ -101,7 +101,7 @@ export function ShotTabContent({
// 确认替换角色
const handleConfirmReplace = (selectedShots: string[], addToLibrary: boolean) => {
};
// 点击按钮重新生成
@ -252,7 +252,7 @@ export function ShotTabContent({
);
})}
</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" />
@ -261,7 +261,7 @@ export function ShotTabContent({
{/* 下部分 */}
<motion.div
<motion.div
className="grid grid-cols-2 gap-4 w-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -296,8 +296,8 @@ export function ShotTabContent({
<motion.button
onClick={() => 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'
${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 }}
@ -336,7 +336,7 @@ export function ShotTabContent({
<div className="grid grid-cols-2 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
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 }}
@ -346,7 +346,7 @@ export function ShotTabContent({
</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
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"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
@ -357,7 +357,7 @@ export function ShotTabContent({
</div>
</div>
</motion.div>
<FloatingGlassPanel
@ -381,4 +381,4 @@ export function ShotTabContent({
/>
</div>
);
}
}