新增文本解析适配器和视频编辑数据处理逻辑,重构分镜编辑器以支持角色和对话内容的动态加载,优化角色标记和文本节点的处理方式。

This commit is contained in:
北枳 2025-08-10 15:02:04 +08:00
parent c48b706e9a
commit 03aa092a08
5 changed files with 638 additions and 192 deletions

View File

@ -0,0 +1,221 @@
import { ContentItem, LensType, SimpleCharacter } from '../domain/valueObject';
// 定义角色属性接口
interface CharacterAttributes {
name: string;
// gender: string;
// age: string;
avatar: string;
}
// 定义文本节点接口
interface TextNode {
type: 'text';
text: string;
}
// 定义角色标记节点接口
interface CharacterTokenNode {
type: 'characterToken';
attrs: CharacterAttributes;
}
// 定义内容节点类型(文本或角色标记)
type ContentNode = TextNode | CharacterTokenNode;
// 定义段落接口
interface Paragraph {
type: 'paragraph';
content: ContentNode[];
}
// 定义shot 接口
interface Shot {
name: string;
shotDescContent: Paragraph[];
shotDialogsContent: Paragraph[];
}
export class TextToShotAdapter {
/**
*
* @param text
* @param roles
* @returns ContentNode[]
*/
public static parseText(text: string, roles: SimpleCharacter[]): ContentNode[] {
const nodes: ContentNode[] = [];
let currentText = text;
// 按角色名称长度降序排序,避免短名称匹配到长名称的一部分
const sortedRoles = [...roles].sort((a, b) => b.name.length - a.name.length);
while (currentText.length > 0) {
let matchFound = false;
// 尝试匹配角色
for (const role of sortedRoles) {
if (currentText.startsWith(role.name)) {
// 如果当前文本以角色名开头
if (currentText.length > role.name.length) {
// 添加角色标记节点
nodes.push({
type: 'characterToken',
attrs: {
name: role.name,
avatar: role.imageUrl
}
});
// 移除已处理的角色名
currentText = currentText.slice(role.name.length);
matchFound = true;
break;
}
}
}
if (!matchFound) {
// 如果没有找到角色匹配,处理普通文本
// 查找下一个可能的角色名位置
let nextRoleIndex = currentText.length;
for (const role of sortedRoles) {
const index = currentText.indexOf(role.name);
if (index !== -1 && index < nextRoleIndex) {
nextRoleIndex = index;
}
}
// 添加文本节点
const textContent = currentText.slice(0, nextRoleIndex);
if (textContent) {
nodes.push({
type: 'text',
text: textContent
});
}
// 移除已处理的文本
currentText = currentText.slice(nextRoleIndex);
}
}
return nodes;
}
private readonly ShotData: Shot;
constructor(shotData: Shot) {
this.ShotData = shotData;
}
toShot() {
return this.ShotData;
}
/**
* LensType Paragraph
* @param lensType LensType
* @returns Paragraph
*/
public static fromLensType(lensType: LensType, roles: SimpleCharacter[]): Shot {
const shotDescContent: Paragraph[] = [];
const shotDialogsContent: Paragraph[] = [];
// 处理镜头描述 通过roles name 匹配镜头描述中出现的角色 并添加到shotDescContent
if (lensType.script) {
const descNodes = TextToShotAdapter.parseText(lensType.script, roles);
shotDescContent.push({
type: 'paragraph',
content: descNodes
});
}
// 处理对话内容 通过roles name 匹配对话内容中出现的角色 并添加到shotDialogsContent
lensType.content.forEach(item => {
const dialogNodes = TextToShotAdapter.parseText(item.content, roles);
// 确保对话内容以角色标记开始
const roleMatch = roles.find(role => role.name === item.roleName);
if (roleMatch) {
const dialogContent: Paragraph = {
type: 'paragraph',
content: [{
type: 'characterToken',
attrs: {
name: roleMatch.name,
avatar: roleMatch.imageUrl
}},
...dialogNodes
]
};
shotDialogsContent.push(dialogContent);
}
});
return {
name: lensType.name,
shotDescContent,
shotDialogsContent
};
}
/**
* Paragraph LensType
* @param paragraphData Paragraph
* @returns LensType
*/
/**
* Shot LensType
* @param shotData Shot
* @returns LensType
*/
public static toLensType(shotData: Shot): LensType {
const content: ContentItem[] = [];
let currentScript = '';
// 处理镜头描述
if (shotData.shotDescContent.length > 0) {
// 合并所有描述段落的文本内容
shotData.shotDescContent.forEach(paragraph => {
paragraph.content.forEach(node => {
if (node.type === 'text') {
currentScript += node.text;
}
if (node.type === 'characterToken') {
currentScript += node.attrs.name;
}
});
});
}
// 处理对话内容
shotData.shotDialogsContent.forEach(paragraph => {
let dialogRoleName = '';
let dialogContent = '';
// 遍历段落内容
paragraph.content.forEach((node, index) => {
if (node.type === 'characterToken') {
// 记录说话角色的名称
index === 0 && (dialogRoleName = node.attrs.name);
index !== 0 && (dialogContent += node.attrs.name);
} else if (node.type === 'text') {
// 累积对话内容
dialogContent += node.text;
}
});
// 如果有角色名和对话内容,添加到结果中
if (dialogRoleName && dialogContent) {
content.push({
roleName: dialogRoleName,
content: dialogContent.trim()
});
}
});
return new LensType(
shotData.name, // 使用 Shot 中的 name
currentScript.trim(),
content
);
}
}

View File

@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { useShotService } from "@/app/service/Interaction/ShotService";
import { useSearchParams } from 'next/navigation';
const mockShotData = [
{
id: '1',
name: 'Shot 1',
sketchUrl: 'https://example.com/sketch.png',
videoUrl: ['https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ1-0-20250725023719.mp4'],
status: 1, // 0:视频加载中 1:任务已完成 2:任务失败
lens: [
{
name: 'Shot 1',
script: '镜头聚焦在 President Alfred King 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。',
content: [{
roleName: 'President Alfred King',
content: '我需要一个镜头,镜头聚焦在 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。'
}]
}
]
}, {
id: '2',
name: 'Shot 2',
sketchUrl: 'https://example.com/sketch.png',
videoUrl: ['https://video-base-imf.oss-ap-southeast-7.aliyuncs.com/uploads/FJ3-0-20250725023725.mp4'],
status: 1, // 0:视频加载中 1:任务已完成 2:任务失败
lens: [
{
name: 'Shot 1',
script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。',
content: [{
roleName: 'Samuel Ryan',
content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。'
}]
}
]
}, {
id: '3',
name: 'Shot 3',
sketchUrl: 'https://example.com/sketch.png',
videoUrl: [],
status: 0, // 0:视频加载中 1:任务已完成 2:任务失败
lens: [
{
name: 'Shot 1',
script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。',
content: [{
roleName: 'Samuel Ryan',
content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。'
}]
}
]
}, {
id: '4',
name: 'Shot 4',
sketchUrl: 'https://example.com/sketch.png',
videoUrl: [],
status: 2, // 0:视频加载中 1:任务已完成 2:任务失败
lens: [
{
name: 'Shot 1',
script: '镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。',
content: [{
roleName: 'Samuel Ryan',
content: '我需要一个镜头,镜头聚焦在 Samuel Ryan 愤怒的脸上,他被压制住了。他发出一声绝望的吼叫,盖过了枪声。'
}]
}
]
}
]
export const useEditData = (tabType: string) => {
const searchParams = useSearchParams();
const projectId = searchParams.get('episodeId') || '';
const [loading, setLoading] = useState(true);
const [shotData, setShotData] = useState<any[]>(mockShotData);
const {
videoSegments,
getVideoSegmentList,
setSelectedSegment,
regenerateVideoSegment
} = useShotService();
useEffect(() => {
if (tabType === 'shot') {
getVideoSegmentList(projectId).then(() => {
console.log('useEditData-----videoSegments', videoSegments);
// setShotData(videoSegments);
setLoading(false);
}).catch((err) => {
console.log('useEditData-----err', err);
setShotData(mockShotData);
setLoading(false);
});
}
}, [tabType]);
return {
loading,
shotData,
setSelectedSegment,
regenerateVideoSegment
}
}

View File

@ -1,8 +1,8 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
import { motion, AnimatePresence } from 'framer-motion'
import { useState } from 'react'
import { Check } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { Check, CircleUserRound } from 'lucide-react'
interface CharacterAttributes {
id: string | null;
@ -22,10 +22,59 @@ interface CharacterTokenOptions {
}
export function CharacterToken(props: ReactNodeViewProps) {
const [showRoleList, setShowRoleList] = useState(false)
const { name, avatar } = props.node.attrs as CharacterAttributes
const extension = props.extension as Node<CharacterTokenOptions>
const roles = extension.options.roles || []
const [showRoleList, setShowRoleList] = useState(false);
const [listPosition, setListPosition] = useState({ top: 0, left: 0 });
const { name, avatar } = props.node.attrs as CharacterAttributes;
const extension = props.extension as Node<CharacterTokenOptions>;
const roles = extension.options.roles || [];
const tokenRef = useRef<HTMLSpanElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// 计算下拉列表的位置
const updateListPosition = () => {
if (!tokenRef.current || !listRef.current) return;
const tokenRect = tokenRef.current.getBoundingClientRect();
const listRect = listRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// 计算理想的顶部位置在token下方
let top = tokenRect.bottom + 8; // 8px 间距
let left = tokenRect.left;
// 检查是否超出底部
if (top + listRect.height > viewportHeight) {
// 如果超出底部将列表显示在token上方
top = tokenRect.top - listRect.height - 8;
}
// 检查是否超出右侧
if (left + listRect.width > viewportWidth) {
// 如果超出右侧,将列表右对齐
left = viewportWidth - listRect.width - 8;
}
// 确保不会超出左侧
left = Math.max(8, left);
setListPosition({ top, left });
};
// 监听窗口大小变化
useEffect(() => {
if (showRoleList) {
updateListPosition();
window.addEventListener('resize', updateListPosition);
window.addEventListener('scroll', updateListPosition);
return () => {
window.removeEventListener('resize', updateListPosition);
window.removeEventListener('scroll', updateListPosition);
};
}
}, [showRoleList]);
const handleRoleSelect = (role: Role) => {
const { editor } = props;
@ -47,11 +96,16 @@ export function CharacterToken(props: ReactNodeViewProps) {
return (
<NodeViewWrapper
as="span"
ref={tokenRef}
data-alt="character-token"
contentEditable={false}
className="relative inline text-blue-400 cursor-pointer hover:text-blue-300 transition-colors duration-200"
onMouseLeave={() => setShowRoleList(false)}
onMouseEnter={() => setShowRoleList(true)}
onMouseEnter={() => {
setShowRoleList(true);
// 延迟一帧执行位置更新,确保列表已渲染
requestAnimationFrame(updateListPosition);
}}
>
{name}
@ -63,7 +117,12 @@ export function CharacterToken(props: ReactNodeViewProps) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.2 }}
className="absolute top-full left-0 mt-2 w-64 rounded-lg backdrop-blur-md bg-white/10 border border-white/20 p-2 z-50"
ref={listRef}
className="fixed w-64 rounded-lg backdrop-blur-md bg-white/10 border border-white/20 p-2 z-[51]"
style={{
top: listPosition.top,
left: listPosition.left
}}
>
<div className="space-y-1">
{roles.map((role) => {
@ -93,6 +152,26 @@ export function CharacterToken(props: ReactNodeViewProps) {
</div>
);
})}
{/* 旁白 */}
<div
data-alt="role-item"
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors duration-200
${name === '旁白' ? 'bg-blue-500/20 text-blue-400' : 'hover:bg-white/5 text-gray-200'}`}
onClick={() => handleRoleSelect({ name: '旁白', url: '' })}
>
<div className="relative">
<CircleUserRound
className={`w-10 h-10 rounded-full border transition-all duration-200`}
/>
{name === '旁白' && (
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
<Check className="w-3 h-3 text-white" />
</div>
)}
</div>
<span className="flex-1"></span>
</div>
</div>
</motion.div>
)}

View File

@ -1,10 +1,12 @@
import React, { forwardRef, useRef, useState } from "react";
import React, { forwardRef, useEffect, useRef, useState } from "react";
import { Plus, X, UserRoundPlus, MessageCirclePlus, MessageCircleMore, ClipboardType } from "lucide-react";
import ShotEditor from "./ShotEditor";
import { toast } from "sonner";
import { TextToShotAdapter } from "@/app/service/adapter/textToShot";
interface Shot {
id: string;
name: string;
shotDescContent: any[];
shotDialogsContent: any[];
}
@ -21,7 +23,7 @@ interface CharacterToken {
const mockShotsData = [
{
id: 'shot1',
name: 'shot1',
shotDescContent: [{
type: 'paragraph',
content: [
@ -43,7 +45,7 @@ const mockShotsData = [
]
const createEmptyShot = (): Shot => ({
id: `shot${Date.now()}`,
name: `shot${Date.now()}`,
shotDescContent: [{
type: 'paragraph',
content: [
@ -60,14 +62,35 @@ const createEmptyShot = (): Shot => ({
interface ShotsEditorProps {
roles: any[];
shotInfo: any[];
style?: React.CSSProperties;
}
export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles }, ref) => {
export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles, shotInfo, style }, ref) => {
const [currentShotIndex, setCurrentShotIndex] = useState(0);
const [shots, setShots] = useState<Shot[]>(mockShotsData);
const [shots, setShots] = useState<Shot[]>([]);
const descEditorRef = useRef<any>(null);
const dialogEditorRef = useRef<any>(null);
useEffect(() => {
console.log('-==========shotInfo===========-', shotInfo);
if (shotInfo) {
const shots = shotInfo.map((shot) => {
return TextToShotAdapter.fromLensType(shot, roles);
});
console.log('-==========shots===========-', shots);
setShots(shots as Shot[]);
}
}, [shotInfo]);
const getShotInfo = () => {
console.log('-==========shots===========-', shots);
const shotInfo = shots.map((shot) => {
return TextToShotAdapter.toLensType(shot);
});
return shotInfo;
}
const addShot = () => {
if (shots.length > 3) {
toast.error('不能超过4个分镜', {
@ -167,16 +190,17 @@ export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles }, ref) =>
// 暴露方法给父组件
React.useImperativeHandle(ref, () => ({
addShot,
getShotInfo
}));
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2" style={style}>
{/* 分镜标签(可删除)、新增分镜标签 */}
<div data-alt="shots-tabs" className="flex items-center gap-2">
<div className="flex gap-2 flex-wrap">
{shots.map((shot, index) => (
<div
key={shot.id}
key={shot.name}
data-alt="shot-tab"
className={`group flex items-center gap-2 px-3 py-1.5 rounded-md cursor-pointer transition-all
${currentShotIndex === index
@ -212,60 +236,63 @@ export const ShotsEditor = forwardRef<any, ShotsEditorProps>(({ roles }, ref) =>
</div>
{/* 分镜内容 */}
<div className="flex flex-col gap-3 border border-white/10 p-2 rounded-[0.5rem]" key={currentShotIndex}>
{/* 分镜描述 添加角色 */}
<div data-alt="shot-description-section" className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ClipboardType className="w-5 h-5 text-white/60" />
<span className="text-lg font-medium text-white/60"></span>
<button
data-alt="add-character-desc"
className="p-1 rounded-md"
onClick={() => handleAddCharacterToDesc()}
>
<UserRoundPlus className="w-5 h-5 text-blue-600" />
</button>
{shots[currentShotIndex] && (
<div className="flex flex-col gap-3 border border-white/10 p-2 rounded-[0.5rem]" key={currentShotIndex} style={{minHeight: 'calc(100% - 2rem)'}}>
{/* 分镜描述 添加角色 */}
<div data-alt="shot-description-section" className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ClipboardType className="w-5 h-5 text-white/60" />
<span className="text-lg font-medium text-white/60"></span>
<button
data-alt="add-character-desc"
className="p-1 rounded-md"
onClick={() => handleAddCharacterToDesc()}
>
<UserRoundPlus className="w-5 h-5 text-blue-600" />
</button>
</div>
{/* 分镜描述内容 可视化编辑 */}
<ShotEditor
ref={descEditorRef}
content={shots[currentShotIndex].shotDescContent}
onCharacterClick={() => {}}
roles={roles}
/>
</div>
{/* 分镜描述内容 可视化编辑 */}
<ShotEditor
ref={descEditorRef}
content={shots[currentShotIndex].shotDescContent}
onCharacterClick={() => {}}
roles={roles}
/>
</div>
{/* 分镜对话 添加角色 添加对话 */}
<div data-alt="shot-dialog-section" className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<MessageCircleMore className="w-5 h-5 text-white/60" />
<span className="text-lg font-medium text-white/60"></span>
<button
data-alt="add-character-dialog"
className="p-1 rounded-md"
onClick={() => handleAddCharacterToDialog()}
>
<UserRoundPlus className="w-5 h-5 text-blue-600" />
</button>
<button
data-alt="add-new-dialog"
className="p-1 rounded-md"
onClick={() => handleAddNewDialog()}
>
<MessageCirclePlus className="w-5 h-5 text-blue-600" />
</button>
</div>
{/* 分镜对话 添加角色 添加对话 */}
<div data-alt="shot-dialog-section" className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<MessageCircleMore className="w-5 h-5 text-white/60" />
<span className="text-lg font-medium text-white/60"></span>
<button
data-alt="add-character-dialog"
className="p-1 rounded-md"
onClick={() => handleAddCharacterToDialog()}
>
<UserRoundPlus className="w-5 h-5 text-blue-600" />
</button>
<button
data-alt="add-new-dialog"
className="p-1 rounded-md"
onClick={() => handleAddNewDialog()}
>
<MessageCirclePlus className="w-5 h-5 text-blue-600" />
</button>
{/* 分镜对话内容 可视化编辑 */}
<ShotEditor
ref={dialogEditorRef}
content={shots[currentShotIndex].shotDialogsContent}
onCharacterClick={() => {}}
roles={roles}
/>
</div>
{/* 分镜对话内容 可视化编辑 */}
<ShotEditor
ref={dialogEditorRef}
content={shots[currentShotIndex].shotDialogsContent}
onCharacterClick={() => {}}
roles={roles}
/>
</div>
</div>
)}
</div>
);
});

View File

@ -2,18 +2,15 @@
import React, { useRef, useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Trash2, RefreshCw, Play, Pause, Volume2, VolumeX, Upload, Library, Video, User, MapPin, Settings, Loader2, X, Plus } from 'lucide-react';
import { GlassIconButton } from './glass-icon-button';
import { RefreshCw, User, Loader2, X, Plus, Video, CircleX } from 'lucide-react';
import { cn } from '@/public/lib/utils';
import { ReplaceVideoModal } from './replace-video-modal';
import { MediaPropertiesModal } from './media-properties-modal';
import { DramaLineChart } from './drama-line-chart';
import { PersonDetection, PersonDetectionScene } from './person-detection';
import { ShotsEditor } from './shot-editor/ShotsEditor';
import { CharacterLibrarySelector } from './character-library-selector';
import FloatingGlassPanel from './FloatingGlassPanel';
import { ReplaceCharacterPanel, mockShots, mockCharacter } from './replace-character-panel';
import HorizontalScroller from './HorizontalScroller';
import { useEditData } from '@/components/pages/work-flow/use-edit-data';
interface ShotTabContentProps {
taskSketch: any[];
@ -30,10 +27,16 @@ export function ShotTabContent({
isPlaying: externalIsPlaying = true,
roles = []
}: ShotTabContentProps) {
const editorRef = useRef<any>(null);
const {
loading,
shotData,
setSelectedSegment,
regenerateVideoSegment
} = useEditData('shot');
const [selectedIndex, setSelectedIndex] = useState(0);
const videoPlayerRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying);
const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false);
const [detections, setDetections] = useState<PersonDetection[]>([]);
const [scanState, setScanState] = useState<'idle' | 'scanning' | 'detected'>('idle');
@ -41,19 +44,21 @@ export function ShotTabContent({
const [isReplaceLibraryOpen, setIsReplaceLibraryOpen] = useState(false);
const [isReplacePanelOpen, setIsReplacePanelOpen] = useState(false);
const [shots, setShots] = useState<any[]>([]);
const shotsEditorRef = useRef<any>(null);
// 监听当前选中index变化
useEffect(() => {
console.log('shotTabContent-----shotData', shotData);
if (shotData.length > 0) {
setSelectedSegment(shotData[selectedIndex]);
}
}, [selectedIndex]);
// 监听外部播放状态变化
useEffect(() => {
setIsPlaying(externalIsPlaying);
}, [externalIsPlaying]);
// 确保 taskSketch 是数组
const sketches = Array.isArray(taskSketch) ? taskSketch : [];
// 视频播放控制
useEffect(() => {
if (videoPlayerRef.current) {
@ -125,6 +130,18 @@ export function ShotTabContent({
};
// 点击按钮重新生成
const handleRegenerate = () => {
console.log('regenerate');
const shotInfo = shotsEditorRef.current.getShotInfo();
console.log('shotTabContent-----shotInfo', shotInfo);
setSelectedSegment({
...shotData[selectedIndex],
lens: shotInfo
});
regenerateVideoSegment();
};
// 新增分镜
const handleAddShot = () => {
console.log('add shot');
@ -134,14 +151,26 @@ export function ShotTabContent({
// 切换选择分镜
const handleSelectShot = (index: number) => {
// 切换前 判断数据是否发生变化
onSketchSelect(index);
setSelectedIndex(index);
};
// 如果没有数据,显示空状态
if (sketches.length === 0) {
// 如果loading 显示loading状态
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] text-white/50">
<p>No sketch data</p>
<div className="w-12 h-12 mb-4 animate-spin rounded-full border-b-2 border-blue-600" />
<p>Loading...</p>
</div>
);
}
// 如果没有数据,显示空状态
if (shotData.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>
);
}
@ -158,28 +187,41 @@ export function ShotTabContent({
<HorizontalScroller
itemWidth={128}
gap={0}
selectedIndex={currentSketchIndex}
selectedIndex={selectedIndex}
onItemClick={(i: number) => handleSelectShot(i)}
>
{sketches.map((sketch, index) => (
{shotData.map((shot, index) => (
<motion.div
key={sketch.id || index}
key={shot.id || index}
className={cn(
'relative flex-shrink-0 w-32 aspect-video rounded-lg overflow-hidden cursor-pointer group',
currentSketchIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
selectedIndex === index ? 'ring-2 ring-blue-500' : 'hover:ring-2 hover:ring-blue-500/50'
)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<video
src={sketch.url}
className="w-full h-full object-cover"
muted
loop
playsInline
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => e.currentTarget.pause()}
/>
{shot.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.status === 1 && (
<video
src={shot.videoUrl[0]}
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 {index + 1}</span>
</div>
@ -202,14 +244,14 @@ export function ShotTabContent({
<HorizontalScroller
itemWidth={'auto'}
gap={0}
selectedIndex={currentSketchIndex}
selectedIndex={selectedIndex}
onItemClick={(i: number) => handleSelectShot(i)}
>
{sketches.map((video, index) => {
const isActive = currentSketchIndex === index;
{shotData.map((shot, index) => {
const isActive = selectedIndex === index;
return (
<motion.div
key={video.id || index}
key={shot.id || index}
className={cn(
'flex-shrink-0 cursor-pointer transition-all duration-300',
isActive ? 'text-white' : 'text-white/50 hover:text-white/80'
@ -220,10 +262,16 @@ export function ShotTabContent({
}}
>
<div className="flex items-center gap-2">
<span className="text-sm whitespace-nowrap">
Shot {index + 1}
<span className="text-sm whitespace-nowrap flex items-center gap-1">
<span className='text-white/50'>Shot {index + 1}</span>
{shot.status === 0 && (
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
)}
{shot.status === 2 && (
<CircleX className="w-4 h-4 text-red-500" />
)}
</span>
{index < sketches.length - 1 && (
{index < shotData.length - 1 && (
<span className="text-white/20">|</span>
)}
</div>
@ -249,94 +297,66 @@ export function ShotTabContent({
{/* 视频预览和操作 */}
<div className="space-y-4 col-span-1">
{/* 选中的视频预览 */}
<motion.div
className="aspect-video rounded-lg overflow-hidden relative group"
layoutId={`video-preview-${currentSketchIndex}`}
>
<PersonDetectionScene
videoSrc={sketches[currentSketchIndex]?.url}
detections={detections}
triggerScan={scanState === 'scanning'}
onScanTimeout={handleScanTimeout}
onScanExit={handleScanTimeout}
onDetectionsChange={handleDetectionsChange}
onPersonClick={handlePersonClick}
/>
{/* <video
ref={videoPlayerRef}
src={sketches[currentSketchIndex]?.url}
className="w-full h-full object-cover"
loop
autoPlay={false}
playsInline
controls
muted={isMuted}
onTimeUpdate={handleTimeUpdate}
/> */}
<motion.div className='absolute top-4 right-4 flex gap-2'>
{/* 人物替换按钮 */}
<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'
: 'bg-black/50 hover:bg-black/70 text-white'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
<>
{shotData[selectedIndex]?.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>
)}
{shotData[selectedIndex]?.status === 1 && (
<motion.div
className="aspect-video rounded-lg overflow-hidden relative group"
layoutId={`video-preview-${selectedIndex}`}
>
{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={() => console.log('Replace scene')}
className="p-2 bg-black/50 hover:bg-black/70
text-white rounded-full backdrop-blur-sm transition-colors z-10"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<MapPin className="w-4 h-4" />
</motion.button> */}
{/* 运镜按钮 */}
{/* <motion.button
onClick={() => console.log('Replace shot')}
disabled={true}
className="p-2 bg-black/50 hover:bg-black/70
text-white rounded-full backdrop-blur-sm transition-colors z-10"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Video className="w-4 h-4" />
</motion.button> */}
{/* 更多设置 点击打开 More properties 弹窗 */}
{/* <motion.button
className='p-2 bg-black/50 hover:bg-black/70
text-white rounded-full backdrop-blur-sm transition-colors z-10'
style={{textDecorationLine: 'underline'}}
onClick={() => setIsMediaPropertiesModalOpen(true)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Settings className="w-4 h-4" />
</motion.button> */}
</motion.div>
</motion.div>
<PersonDetectionScene
videoSrc={shotData[selectedIndex]?.videoUrl[0]}
detections={detections}
triggerScan={scanState === 'scanning'}
onScanTimeout={handleScanTimeout}
onScanExit={handleScanTimeout}
onDetectionsChange={handleDetectionsChange}
onPersonClick={handlePersonClick}
/>
<motion.div className='absolute top-4 right-4 flex gap-2'>
{/* 人物替换按钮 */}
<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'
: '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.div>
</motion.div>
)}
{shotData[selectedIndex]?.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"></span>
</div>
)}
</>
</div>
{/* 基础配置 */}
<div className='space-y-4 col-span-1'>
<div className='space-y-4 col-span-1' key={selectedIndex}>
<ShotsEditor
ref={shotsEditorRef}
roles={roles}
shotInfo={shotData[selectedIndex].lens}
style={{height: 'calc(100% - 4rem)'}}
/>
{/* 重新生成按钮、新增分镜按钮 */}
@ -352,7 +372,7 @@ export function ShotTabContent({
<span>Add Shot</span>
</motion.button>
<motion.button
onClick={() => console.log('Regenerate')}
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"
whileHover={{ scale: 1.02 }}
@ -367,15 +387,6 @@ export function ShotTabContent({
</motion.div>
{/* Media Properties 弹窗 */}
<MediaPropertiesModal
isOpen={isMediaPropertiesModalOpen}
onClose={() => setIsMediaPropertiesModalOpen(false)}
taskSketch={taskSketch}
currentSketchIndex={currentSketchIndex}
onSketchSelect={onSketchSelect}
/>
<FloatingGlassPanel
open={isReplacePanelOpen}
width='66vw'