forked from 77media/video-flow
shot tab video 支持 修改分镜描述、重生视频、识别角色
This commit is contained in:
parent
e42f5269ca
commit
484b7099f2
81
components/ui/CharacterToken.tsx
Normal file
81
components/ui/CharacterToken.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export const CharacterToken = Node.create({
|
||||||
|
name: 'characterToken',
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: { default: null },
|
||||||
|
name: { default: '角色名' },
|
||||||
|
avatar: { default: '' },
|
||||||
|
gender: { default: '未知' },
|
||||||
|
age: { default: '-' },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-character]' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['span', mergeAttributes({ 'data-character': '' }, HTMLAttributes), HTMLAttributes.name]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(CharacterView)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function CharacterView({ node }) {
|
||||||
|
const [showCard, setShowCard] = useState(false)
|
||||||
|
const { name, avatar, gender, age } = node.attrs
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
console.log('点击角色:', name)
|
||||||
|
alert(`点击角色:${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
as="span"
|
||||||
|
contentEditable={false}
|
||||||
|
className="relative inline-block px-2 py-0.5 rounded bg-yellow-100 text-yellow-900 font-semibold cursor-pointer border border-yellow-300 shadow-sm hover:bg-yellow-200"
|
||||||
|
onMouseEnter={() => setShowCard(true)}
|
||||||
|
onMouseLeave={() => setShowCard(false)}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showCard && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 4 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute top-full left-0 mt-2 w-60 rounded-lg bg-white text-black shadow-xl p-3 z-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={avatar || 'https://placekitten.com/64/64'}
|
||||||
|
alt={name}
|
||||||
|
className="w-12 h-12 rounded-full border"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-base">{name}</div>
|
||||||
|
<div className="text-sm text-gray-600">{gender} / {age}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -29,7 +29,7 @@ export default function ImageBlurTransition({ src, alt = '', width = 480, height
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative rounded-xl shadow-xl ${className}`}
|
className={`relative rounded-xl ${className}`}
|
||||||
style={{
|
style={{
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
|||||||
@ -195,7 +195,7 @@ export function CharacterTabContent({
|
|||||||
src={currentRole.url}
|
src={currentRole.url}
|
||||||
alt={currentRole.name}
|
alt={currentRole.name}
|
||||||
width='100%'
|
width='100%'
|
||||||
height='100%'
|
height='auto'
|
||||||
/>
|
/>
|
||||||
{/* 应用角色按钮 */}
|
{/* 应用角色按钮 */}
|
||||||
<div className='absolute top-3 right-3 flex gap-2'>
|
<div className='absolute top-3 right-3 flex gap-2'>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { X, Image, Users, Video, Music, Settings, FileText, Maximize, Minimize }
|
|||||||
import { cn } from '@/public/lib/utils';
|
import { cn } from '@/public/lib/utils';
|
||||||
import ScriptTabContent from './script-tab-content';
|
import ScriptTabContent from './script-tab-content';
|
||||||
import { SceneTabContent } from './scene-tab-content';
|
import { SceneTabContent } from './scene-tab-content';
|
||||||
import { VideoTabContent } from './video-tab-content';
|
import { ShotTabContent } from './shot-tab-content';
|
||||||
import { SettingsTabContent } from './settings-tab-content';
|
import { SettingsTabContent } from './settings-tab-content';
|
||||||
import { CharacterTabContent } from './character-tab-content';
|
import { CharacterTabContent } from './character-tab-content';
|
||||||
import { MusicTabContent } from './music-tab-content';
|
import { MusicTabContent } from './music-tab-content';
|
||||||
@ -110,7 +110,7 @@ export function EditModal({
|
|||||||
);
|
);
|
||||||
case '3':
|
case '3':
|
||||||
return (
|
return (
|
||||||
<VideoTabContent
|
<ShotTabContent
|
||||||
taskSketch={sketchVideo}
|
taskSketch={sketchVideo}
|
||||||
currentSketchIndex={currentIndex}
|
currentSketchIndex={currentIndex}
|
||||||
onSketchSelect={hanldeChangeSelect}
|
onSketchSelect={hanldeChangeSelect}
|
||||||
|
|||||||
397
components/ui/person-detection.tsx
Normal file
397
components/ui/person-detection.tsx
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
import React, { useEffect, useRef, useState, RefObject } from "react";
|
||||||
|
import { motion, useAnimation, AnimatePresence } from "framer-motion";
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
|
// 人物框组件
|
||||||
|
const PersonBox = React.forwardRef<HTMLDivElement, {
|
||||||
|
person: PersonDetection;
|
||||||
|
}>(({ person }, ref) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
className="absolute border-2 border-cyan-400/70 rounded-md shadow-lg z-30"
|
||||||
|
style={{
|
||||||
|
top: `${person.position.top}px`,
|
||||||
|
left: `${person.position.left}px`,
|
||||||
|
width: `${person.position.width}px`,
|
||||||
|
height: `${person.position.height}px`,
|
||||||
|
boxShadow: "0 0 20px rgba(6, 182, 212, 0.3)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PersonBox.displayName = 'PersonBox';
|
||||||
|
|
||||||
|
export type PersonDetection = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
backgroundImage?: string;
|
||||||
|
videoSrc?: string;
|
||||||
|
detections: PersonDetection[];
|
||||||
|
triggerScan: boolean;
|
||||||
|
onScanStart?: (currentTime?: number) => void;
|
||||||
|
onScanTimeout?: () => void;
|
||||||
|
onScanExit?: () => void; // 扫描退出回调
|
||||||
|
scanTimeout?: number;
|
||||||
|
isScanFailed?: boolean; // 外部传入的失败状态
|
||||||
|
onDetectionsChange?: (detections: PersonDetection[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PersonDetectionScene: React.FC<Props> = ({
|
||||||
|
backgroundImage,
|
||||||
|
videoSrc,
|
||||||
|
detections,
|
||||||
|
triggerScan,
|
||||||
|
onScanStart,
|
||||||
|
onScanTimeout,
|
||||||
|
onScanExit,
|
||||||
|
scanTimeout = 10000,
|
||||||
|
isScanFailed = false,
|
||||||
|
onDetectionsChange
|
||||||
|
}) => {
|
||||||
|
const scanControls = useAnimation();
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [scanStatus, setScanStatus] = useState<'idle' | 'scanning' | 'timeout' | 'failed'>('idle');
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
const exitTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
|
||||||
|
// 处理扫描状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (triggerScan && detections.length === 0) {
|
||||||
|
setScanStatus('scanning');
|
||||||
|
// 设置超时定时器
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setScanStatus('timeout');
|
||||||
|
onScanTimeout?.();
|
||||||
|
}, scanTimeout);
|
||||||
|
} else {
|
||||||
|
// 清除所有定时器
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
if (exitTimeoutRef.current) {
|
||||||
|
clearTimeout(exitTimeoutRef.current);
|
||||||
|
}
|
||||||
|
setScanStatus('idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
if (exitTimeoutRef.current) {
|
||||||
|
clearTimeout(exitTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [triggerScan, detections.length, scanTimeout, onScanTimeout]);
|
||||||
|
|
||||||
|
// 处理外部失败状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (isScanFailed && scanStatus === 'scanning') {
|
||||||
|
setScanStatus('failed');
|
||||||
|
}
|
||||||
|
}, [isScanFailed, scanStatus]);
|
||||||
|
|
||||||
|
// 处理失败/超时自动退出
|
||||||
|
useEffect(() => {
|
||||||
|
if (scanStatus === 'timeout' || scanStatus === 'failed') {
|
||||||
|
exitTimeoutRef.current = setTimeout(() => {
|
||||||
|
setScanStatus('idle');
|
||||||
|
onScanExit?.();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (exitTimeoutRef.current) {
|
||||||
|
clearTimeout(exitTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [scanStatus, onScanExit]);
|
||||||
|
|
||||||
|
// 处理视频/图片源变化
|
||||||
|
useEffect(() => {
|
||||||
|
setScanStatus('idle');
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
if (exitTimeoutRef.current) {
|
||||||
|
clearTimeout(exitTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, [videoSrc, backgroundImage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scanStatus !== 'idle') {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
onScanStart?.(videoRef.current.currentTime);
|
||||||
|
} else {
|
||||||
|
onScanStart?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
scanControls.start({
|
||||||
|
opacity: [0, 1, 1, 0],
|
||||||
|
scale: [0.98, 1, 1, 0.98],
|
||||||
|
y: ["-120%", "120%"],
|
||||||
|
transition: {
|
||||||
|
duration: 3,
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: "loop",
|
||||||
|
times: [0, 0.2, 0.8, 1],
|
||||||
|
ease: "easeInOut"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
scanControls.stop();
|
||||||
|
}
|
||||||
|
}, [triggerScan, scanStatus, scanControls, onScanStart]);
|
||||||
|
|
||||||
|
// 监听检测结果变化
|
||||||
|
useEffect(() => {
|
||||||
|
onDetectionsChange?.(detections);
|
||||||
|
}, [detections, onDetectionsChange]);
|
||||||
|
|
||||||
|
const isShowError = scanStatus === 'timeout' || scanStatus === 'failed';
|
||||||
|
const isShowScan = scanStatus !== 'idle';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full aspect-video overflow-hidden rounded-xl border border-white/10 bg-black">
|
||||||
|
{/* 背景层 */}
|
||||||
|
{backgroundImage && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${backgroundImage})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{videoSrc && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoSrc}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover z-0"
|
||||||
|
autoPlay
|
||||||
|
muted={false}
|
||||||
|
loop
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 暗化层 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isShowScan && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-black/40 backdrop-blur-sm z-10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 扫描状态提示 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isShowScan && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute top-1/2 left-1/2 z-50 flex flex-col items-center -translate-y-1/2 -translate-x-1/2"
|
||||||
|
>
|
||||||
|
{/* 状态图标 */}
|
||||||
|
<div className={`relative w-12 h-12 mb-2 ${isShowError ? 'scale-110' : ''}`}>
|
||||||
|
{!isShowError ? (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
border: '2px solid rgba(6, 182, 212, 0.5)',
|
||||||
|
borderTopColor: 'rgb(6, 182, 212)',
|
||||||
|
}}
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
border: '2px solid rgba(6, 182, 212, 0.3)',
|
||||||
|
borderRightColor: 'rgb(6, 182, 212)',
|
||||||
|
}}
|
||||||
|
animate={{ rotate: -360 }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-4 bg-cyan-400 rounded-full" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8 }}
|
||||||
|
animate={{
|
||||||
|
scale: [0.8, 1.1, 1],
|
||||||
|
borderColor: ['rgb(239, 68, 68)', 'rgb(239, 68, 68)', 'rgb(239, 68, 68)']
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="w-full h-full rounded-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="w-12 h-12 text-red-500" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 状态文字 */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isShowError ? (
|
||||||
|
<span className="text-cyan-400 text-sm font-medium tracking-wider">
|
||||||
|
智能识别中
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-400 text-sm font-medium tracking-wider">
|
||||||
|
{scanStatus === 'timeout' ? '识别超时,请重试' : '识别失败,请重试'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 扫描动画效果 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isShowScan && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0"
|
||||||
|
>
|
||||||
|
{/* 主扫描线 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute left-0 w-full h-[150px] z-20"
|
||||||
|
animate={scanControls}
|
||||||
|
initial={{ y: "-120%" }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
style={{
|
||||||
|
background: `
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(6, 182, 212, 0.1) 20%,
|
||||||
|
rgba(6, 182, 212, 0.3) 50%,
|
||||||
|
rgba(6, 182, 212, 0.1) 80%,
|
||||||
|
transparent 100%
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
boxShadow: '0 0 30px rgba(6, 182, 212, 0.3)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 扫描线中心光束 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-1/2 left-0 w-full h-[2px]"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(90deg, transparent 0%, rgb(6, 182, 212) 50%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 20px rgb(6, 182, 212)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 扫描网格 */}
|
||||||
|
<div className="absolute inset-0 z-15">
|
||||||
|
<motion.div
|
||||||
|
className="w-full h-full"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(90deg, rgba(6, 182, 212, 0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(0deg, rgba(6, 182, 212, 0.1) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: '40px 40px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.5 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(90deg, rgba(6, 182, 212, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(0deg, rgba(6, 182, 212, 0.05) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: '20px 20px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 四角扫描框 */}
|
||||||
|
<div className="absolute inset-4 z-30 pointer-events-none">
|
||||||
|
<motion.div
|
||||||
|
className="w-full h-full"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
{/* 左上角 */}
|
||||||
|
<div className="absolute top-0 left-0 w-8 h-8 border-l-2 border-t-2 border-cyan-400/70" />
|
||||||
|
{/* 右上角 */}
|
||||||
|
<div className="absolute top-0 right-0 w-8 h-8 border-r-2 border-t-2 border-cyan-400/70" />
|
||||||
|
{/* 左下角 */}
|
||||||
|
<div className="absolute bottom-0 left-0 w-8 h-8 border-l-2 border-b-2 border-cyan-400/70" />
|
||||||
|
{/* 右下角 */}
|
||||||
|
<div className="absolute bottom-0 right-0 w-8 h-8 border-r-2 border-b-2 border-cyan-400/70" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 人物识别框和浮签 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{detections.map((person, index) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={person.id}>
|
||||||
|
<PersonBox person={person} />
|
||||||
|
<motion.div
|
||||||
|
className="absolute z-50 px-3 py-1 text-white text-xs bg-cyan-500/20 border border-cyan-400/30 rounded-md backdrop-blur-md whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
top: `${person.position.top}px`,
|
||||||
|
left: `${person.position.left}px`
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
</motion.div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
87
components/ui/shot-editor/CharacterToken.tsx
Normal file
87
components/ui/shot-editor/CharacterToken.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface CharacterAttributes {
|
||||||
|
id: string | null;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
gender: string;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CharacterToken(props: ReactNodeViewProps) {
|
||||||
|
const [showCard, setShowCard] = useState(false)
|
||||||
|
const { name, avatar, gender, age } = props.node.attrs as CharacterAttributes
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
console.log('点击角色:', name)
|
||||||
|
alert(`点击角色:${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
as="span"
|
||||||
|
contentEditable={false}
|
||||||
|
className="relative inline text-blue-400 cursor-pointer hover:text-blue-300 transition-colors duration-200"
|
||||||
|
onMouseEnter={() => setShowCard(true)}
|
||||||
|
onMouseLeave={() => setShowCard(false)}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showCard && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
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 bg-gray-900 border border-gray-800 shadow-2xl p-4 z-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={avatar || 'https://placekitten.com/64/64'}
|
||||||
|
alt={name}
|
||||||
|
className="w-12 h-12 rounded-full border"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-base text-gray-200">{name}</div>
|
||||||
|
<div className="text-sm text-gray-400">{gender} / {age}岁</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CharacterTokenExtension = Node.create({
|
||||||
|
name: 'characterToken',
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
name: {},
|
||||||
|
gender: {},
|
||||||
|
age: {},
|
||||||
|
avatar: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'character-token' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['character-token', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(CharacterToken);
|
||||||
|
},
|
||||||
|
});
|
||||||
75
components/ui/shot-editor/ReadonlyText.tsx
Normal file
75
components/ui/shot-editor/ReadonlyText.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
import React from 'react'
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
readonlyText: {
|
||||||
|
insertReadonlyText: (text: string) => ReturnType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadonlyText = Node.create({
|
||||||
|
name: 'readonlyText',
|
||||||
|
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true, // 不可拆分
|
||||||
|
selectable: false,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
text: {
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'span[data-readonly-text]',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
mergeAttributes(HTMLAttributes, {
|
||||||
|
'data-readonly-text': '',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ReadonlyTextView)
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertReadonlyText:
|
||||||
|
(text: string) =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.insertContent({
|
||||||
|
type: this.name,
|
||||||
|
attrs: { text },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ReadonlyTextView = (props: any) => {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper
|
||||||
|
as="span"
|
||||||
|
className="text-gray-400 px-1 select-none pointer-events-none"
|
||||||
|
contentEditable={false}
|
||||||
|
data-readonly-text
|
||||||
|
>
|
||||||
|
{props.node.attrs.text}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
components/ui/shot-editor/ShotEditor.tsx
Normal file
125
components/ui/shot-editor/ShotEditor.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { CharacterTokenExtension } from './CharacterToken';
|
||||||
|
import { ShotTitle } from './ShotTitle';
|
||||||
|
import { ReadonlyText } from './ReadonlyText';
|
||||||
|
|
||||||
|
const initialContent = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'shotTitle',
|
||||||
|
attrs: { title: `分镜1` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{ type: 'characterToken', attrs: { name: '张三', gender: '男', age: '28', avatar: 'https://i.pravatar.cc/40?u=z3' }},
|
||||||
|
{ type: 'text', text: ' 从门口走来,皱着眉头说:“你怎么还在这里?”' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'shotTitle',
|
||||||
|
attrs: { title: `分镜2` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{ type: 'characterToken', attrs: { name: '李四', gender: '女', age: '26', avatar: 'https://i.pravatar.cc/40?u=l4' }},
|
||||||
|
{ type: 'text', text: ' 微微低头,没有说话。' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ShotEditorProps {
|
||||||
|
onAddSegment?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShotEditor = React.forwardRef<{ addSegment: () => void }, ShotEditorProps>(function ShotEditor({ onAddSegment }, ref) {
|
||||||
|
const [segments, setSegments] = useState(initialContent.content);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
CharacterTokenExtension,
|
||||||
|
ShotTitle,
|
||||||
|
ReadonlyText,
|
||||||
|
],
|
||||||
|
content: { type: 'doc', content: segments },
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-invert max-w-none min-h-[150px] focus:outline-none'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediatelyRender: false,
|
||||||
|
onCreate: ({ editor }) => {
|
||||||
|
editor.setOptions({ editable: true })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addSegment = () => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
// 自动编号(获取已有 shotTitle 节点数量)
|
||||||
|
const doc = editor.state.doc;
|
||||||
|
let shotCount = 0;
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'paragraph') {
|
||||||
|
shotCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.chain().focus('end').insertContent([
|
||||||
|
{
|
||||||
|
type: 'shotTitle',
|
||||||
|
attrs: { title: `分镜${shotCount + 1}` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: '镜头描述' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.focus('end') // 聚焦到文档末尾
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// 调用外部传入的回调函数
|
||||||
|
onAddSegment?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 暴露 addSegment 方法给父组件
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
addSegment
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-3xl mx-auto relative p-[0.5rem] pb-[2.5rem] border border-white/10 rounded-[0.5rem]">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
{/* <motion.button
|
||||||
|
onClick={addSegment}
|
||||||
|
className="group absolute bottom-[0.5rem] h-8 rounded-full bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 flex items-center justify-center overflow-hidden"
|
||||||
|
initial={{ width: "2rem" }}
|
||||||
|
whileHover={{
|
||||||
|
width: "8rem",
|
||||||
|
transition: { duration: 0.3, ease: "easeInOut" }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center justify-center space-x-1 px-2 h-full">
|
||||||
|
<span className="text-lg">+</span>
|
||||||
|
<span className="text-sm group-hover:opacity-100 opacity-0 transition-all duration-500 w-0 group-hover:w-auto">新增分镜</span>
|
||||||
|
</motion.div>
|
||||||
|
</motion.button> */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ShotEditor;
|
||||||
52
components/ui/shot-editor/ShotTitle.tsx
Normal file
52
components/ui/shot-editor/ShotTitle.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Node, mergeAttributes } from '@tiptap/core'
|
||||||
|
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||||
|
|
||||||
|
// React 组件用于自定义渲染(不可编辑标题)
|
||||||
|
const ShotTitleComponent = (props: any) => {
|
||||||
|
const title = props.node?.attrs?.title || '分镜'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="inline text-gray-500 mr-2 select-none pointer-events-none" contentEditable={false}>
|
||||||
|
{title}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiptap Node 扩展定义
|
||||||
|
export const ShotTitle = Node.create({
|
||||||
|
name: 'shotTitle',
|
||||||
|
group: 'block',
|
||||||
|
atom: true, // 作为原子节点,无法拆解
|
||||||
|
selectable: false,
|
||||||
|
draggable: false,
|
||||||
|
content: '',
|
||||||
|
defining: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
default: '分镜',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'div[data-type="shot-title"]',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'div',
|
||||||
|
mergeAttributes(HTMLAttributes, { 'data-type': 'shot-title', class: 'shot-title' }),
|
||||||
|
HTMLAttributes.title,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ShotTitleComponent)
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Trash2, RefreshCw, Play, Pause, Volume2, VolumeX, Upload, Library, Video, User, MapPin, Settings } from 'lucide-react';
|
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 { GlassIconButton } from './glass-icon-button';
|
||||||
import { cn } from '@/public/lib/utils';
|
import { cn } from '@/public/lib/utils';
|
||||||
import { ReplaceVideoModal } from './replace-video-modal';
|
import { ReplaceVideoModal } from './replace-video-modal';
|
||||||
import { MediaPropertiesModal } from './media-properties-modal';
|
import { MediaPropertiesModal } from './media-properties-modal';
|
||||||
import { DramaLineChart } from './drama-line-chart';
|
import { DramaLineChart } from './drama-line-chart';
|
||||||
|
import { PersonDetection, PersonDetectionScene } from './person-detection';
|
||||||
|
import ShotEditor from './shot-editor/ShotEditor';
|
||||||
|
|
||||||
interface ShotTabContentProps {
|
interface ShotTabContentProps {
|
||||||
taskSketch: any[];
|
taskSketch: any[];
|
||||||
@ -23,6 +25,7 @@ export function ShotTabContent({
|
|||||||
isPlaying: externalIsPlaying = true
|
isPlaying: externalIsPlaying = true
|
||||||
}: ShotTabContentProps) {
|
}: ShotTabContentProps) {
|
||||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
const videosRef = useRef<HTMLDivElement>(null);
|
const videosRef = useRef<HTMLDivElement>(null);
|
||||||
const videoPlayerRef = useRef<HTMLVideoElement>(null);
|
const videoPlayerRef = useRef<HTMLVideoElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying);
|
const [isPlaying, setIsPlaying] = React.useState(externalIsPlaying);
|
||||||
@ -32,6 +35,11 @@ export function ShotTabContent({
|
|||||||
const [activeReplaceMethod, setActiveReplaceMethod] = React.useState<'upload' | 'library' | 'generate'>('upload');
|
const [activeReplaceMethod, setActiveReplaceMethod] = React.useState<'upload' | 'library' | 'generate'>('upload');
|
||||||
const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false);
|
const [isMediaPropertiesModalOpen, setIsMediaPropertiesModalOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const [triggerScan, setTriggerScan] = useState(false);
|
||||||
|
const [detections, setDetections] = useState<PersonDetection[]>([]);
|
||||||
|
const [scanState, setScanState] = useState<'idle' | 'scanning' | 'detected'>('idle');
|
||||||
|
|
||||||
|
|
||||||
// 监听外部播放状态变化
|
// 监听外部播放状态变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsPlaying(externalIsPlaying);
|
setIsPlaying(externalIsPlaying);
|
||||||
@ -87,6 +95,41 @@ export function ShotTabContent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理扫描开始
|
||||||
|
const handleScan = () => {
|
||||||
|
if (scanState === 'detected') {
|
||||||
|
// 如果已经有检测结果,点击按钮退出检测状态
|
||||||
|
setScanState('idle');
|
||||||
|
setDetections([]); // 清除检测结果
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setScanState('scanning');
|
||||||
|
// 模拟检测过程
|
||||||
|
setTimeout(() => {
|
||||||
|
const mockDetections: PersonDetection[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '人物1',
|
||||||
|
position: { top: 0, left: 100, width: 100, height: 200 }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
setDetections(mockDetections);
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理扫描超时/失败
|
||||||
|
const handleScanTimeout = () => {
|
||||||
|
setScanState('idle');
|
||||||
|
setDetections([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理检测到结果
|
||||||
|
const handleDetectionsChange = (newDetections: PersonDetection[]) => {
|
||||||
|
if (newDetections.length > 0 && scanState === 'scanning') {
|
||||||
|
setScanState('detected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 如果没有数据,显示空状态
|
// 如果没有数据,显示空状态
|
||||||
if (sketches.length === 0) {
|
if (sketches.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -189,19 +232,28 @@ export function ShotTabContent({
|
|||||||
|
|
||||||
{/* 下部分 */}
|
{/* 下部分 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="grid grid-cols-3 gap-4 w-full"
|
className="grid grid-cols-2 gap-4 w-full"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
{/* 视频预览和操作 */}
|
{/* 视频预览和操作 */}
|
||||||
<div className="space-y-4 col-span-2">
|
<div className="space-y-4 col-span-1">
|
||||||
{/* 选中的视频预览 */}
|
{/* 选中的视频预览 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="aspect-video rounded-lg overflow-hidden relative group"
|
className="aspect-video rounded-lg overflow-hidden relative group"
|
||||||
layoutId={`video-preview-${currentSketchIndex}`}
|
layoutId={`video-preview-${currentSketchIndex}`}
|
||||||
>
|
>
|
||||||
<video
|
<PersonDetectionScene
|
||||||
|
videoSrc={sketches[currentSketchIndex]?.url}
|
||||||
|
detections={detections}
|
||||||
|
triggerScan={scanState === 'scanning'}
|
||||||
|
onScanTimeout={handleScanTimeout}
|
||||||
|
onScanExit={handleScanTimeout}
|
||||||
|
onDetectionsChange={handleDetectionsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <video
|
||||||
ref={videoPlayerRef}
|
ref={videoPlayerRef}
|
||||||
src={sketches[currentSketchIndex]?.url}
|
src={sketches[currentSketchIndex]?.url}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
@ -211,21 +263,30 @@ export function ShotTabContent({
|
|||||||
controls
|
controls
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
/>
|
/> */}
|
||||||
|
|
||||||
<motion.div className='absolute top-4 right-4 flex gap-2'>
|
<motion.div className='absolute top-4 right-4 flex gap-2'>
|
||||||
{/* 人物替换按钮 */}
|
{/* 人物替换按钮 */}
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={() => console.log('Replace character')}
|
onClick={() => handleScan()}
|
||||||
className="p-2 bg-black/50 hover:bg-black/70
|
className={`p-2 backdrop-blur-sm transition-colors z-10 rounded-full
|
||||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
${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 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<User className="w-4 h-4" />
|
{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>
|
||||||
{/* 场景替换按钮 */}
|
{/* 场景替换按钮 */}
|
||||||
<motion.button
|
{/* <motion.button
|
||||||
onClick={() => console.log('Replace scene')}
|
onClick={() => console.log('Replace scene')}
|
||||||
className="p-2 bg-black/50 hover:bg-black/70
|
className="p-2 bg-black/50 hover:bg-black/70
|
||||||
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
text-white rounded-full backdrop-blur-sm transition-colors z-10"
|
||||||
@ -233,17 +294,7 @@ export function ShotTabContent({
|
|||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<MapPin className="w-4 h-4" />
|
<MapPin className="w-4 h-4" />
|
||||||
</motion.button>
|
</motion.button> */}
|
||||||
{/* Regenerate 按钮 */}
|
|
||||||
<motion.button
|
|
||||||
onClick={() => console.log('Regenerate')}
|
|
||||||
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 }}
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
</motion.button>
|
|
||||||
{/* 运镜按钮 */}
|
{/* 运镜按钮 */}
|
||||||
{/* <motion.button
|
{/* <motion.button
|
||||||
onClick={() => console.log('Replace shot')}
|
onClick={() => console.log('Replace shot')}
|
||||||
@ -257,7 +308,7 @@ export function ShotTabContent({
|
|||||||
</motion.button> */}
|
</motion.button> */}
|
||||||
|
|
||||||
{/* 更多设置 点击打开 More properties 弹窗 */}
|
{/* 更多设置 点击打开 More properties 弹窗 */}
|
||||||
<motion.button
|
{/* <motion.button
|
||||||
className='p-2 bg-black/50 hover:bg-black/70
|
className='p-2 bg-black/50 hover:bg-black/70
|
||||||
text-white rounded-full backdrop-blur-sm transition-colors z-10'
|
text-white rounded-full backdrop-blur-sm transition-colors z-10'
|
||||||
style={{textDecorationLine: 'underline'}}
|
style={{textDecorationLine: 'underline'}}
|
||||||
@ -266,14 +317,41 @@ export function ShotTabContent({
|
|||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
</motion.button>
|
</motion.button> */}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 基础配置查看 场景/人物/运镜/对话 */}
|
{/* 基础配置 */}
|
||||||
<div className='space-y-4 col-span-1'>
|
<div className='space-y-4 col-span-1'>
|
||||||
{/* 场景: */}
|
<ShotEditor ref={editorRef} onAddSegment={() => {
|
||||||
|
// 可以在这里添加其他逻辑
|
||||||
|
console.log('分镜添加成功');
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 重新生成按钮、新增分镜按钮 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<motion.button
|
||||||
|
onClick={() => editorRef.current?.addSegment()}
|
||||||
|
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={() => console.log('Regenerate')}
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
<span>Regenerate</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
3268
package-lock.json
generated
3268
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@floating-ui/react": "^0.27.15",
|
||||||
"@formatjs/intl-localematcher": "^0.6.1",
|
"@formatjs/intl-localematcher": "^0.6.1",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@mdx-js/mdx": "^3.1.0",
|
"@mdx-js/mdx": "^3.1.0",
|
||||||
@ -48,6 +49,9 @@
|
|||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@tensorflow-models/coco-ssd": "^2.2.3",
|
"@tensorflow-models/coco-ssd": "^2.2.3",
|
||||||
"@tensorflow/tfjs": "^4.22.0",
|
"@tensorflow/tfjs": "^4.22.0",
|
||||||
|
"@tiptap/core": "^3.0.7",
|
||||||
|
"@tiptap/react": "^3.0.7",
|
||||||
|
"@tiptap/starter-kit": "^3.0.7",
|
||||||
"@types/gsap": "^1.20.2",
|
"@types/gsap": "^1.20.2",
|
||||||
"@types/node": "20.6.2",
|
"@types/node": "20.6.2",
|
||||||
"@types/react": "18.2.22",
|
"@types/react": "18.2.22",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user