视频修改功能开发

This commit is contained in:
qikongjian 2025-09-22 20:03:47 +08:00
parent 89255f9feb
commit ca4408f5df
9 changed files with 243 additions and 438 deletions

View File

@ -20,8 +20,7 @@ import { showEditingNotification } from "@/components/pages/work-flow/editing-no
// import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
import { exportVideoWithRetry } from '@/utils/export-service';
import { getFirstFrame } from '@/utils/tools';
// 临时禁用视频编辑功能
// import { EditPoint as EditPointType } from './work-flow/video-edit/types';
import { EditPoint as EditPointType } from './work-flow/video-edit/types';
import { AIEditingIframeButton } from './work-flow/ai-editing-iframe';
import { useDeviceType } from '@/hooks/useDeviceType';
import { H5ProgressToastProvider, useH5ProgressToast } from '@/components/ui/h5-progress-toast';
@ -303,9 +302,8 @@ const WorkFlow = React.memo(function WorkFlow() {
// setAiEditingInProgress(false); // 已移除该状态变量
}, []);
// 临时禁用视频编辑功能
// 视频编辑描述提交处理函数
/*const handleVideoEditDescriptionSubmit = useCallback((editPoint: EditPointType, description: string) => {
const handleVideoEditDescriptionSubmit = useCallback((editPoint: EditPointType, description: string) => {
console.log('🎬 视频编辑描述提交:', { editPoint, description });
// 构造编辑消息发送到SmartChatBox
@ -333,7 +331,7 @@ Please process this video editing request.`;
description: `Your edit request for timestamp ${Math.floor(editPoint.timestamp)}s has been submitted successfully.`,
duration: 3
});
}, [currentSketchIndex, isSmartChatBoxOpen]);*/
}, [currentSketchIndex, isSmartChatBoxOpen]);
// 测试导出接口的处理函数(使用封装的导出服务)
const handleTestExport = useCallback(async () => {
@ -520,7 +518,9 @@ Please process this video editing request.`;
isSmartChatBoxOpen={isSmartChatBoxOpen}
onRetryVideo={(video_id) => handleRetryVideo(video_id)}
onSelectView={(view) => setSelectedView(view)}
// 临时禁用视频编辑功能: enableVideoEdit={true} onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
enableVideoEdit={true}
onVideoEditDescriptionSubmit={handleVideoEditDescriptionSubmit}
projectId={episodeId}
/>
)}
</div>

View File

@ -43,6 +43,12 @@ interface H5MediaViewerProps {
onRetryVideo?: (video_id: string) => void;
/** 切换选择视图final 或 video */
onSelectView?: (view: 'final' | 'video') => void;
/** 启用视频编辑功能 */
enableVideoEdit?: boolean;
/** 视频编辑描述提交回调 */
onVideoEditDescriptionSubmit?: (editPoint: any, description: string) => void;
/** 项目ID */
projectId?: string;
}
/**
@ -67,7 +73,10 @@ export function H5MediaViewer({
onGotoCut,
isSmartChatBoxOpen,
onRetryVideo,
onSelectView
onSelectView,
enableVideoEdit,
onVideoEditDescriptionSubmit,
projectId
}: H5MediaViewerProps) {
const carouselRef = useRef<CarouselRef>(null);
const videoRefs = useRef<Array<HTMLVideoElement | null>>([]);

View File

@ -2,7 +2,7 @@
import React, { useRef, useEffect, useState, SetStateAction, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert /*, PenTool*/ } from 'lucide-react';
import { Edit3, Play, Pause, Volume2, VolumeX, Maximize, Minimize, Loader2, X, Scissors, RotateCcw, MessageCircleMore, Download, ArrowDownWideNarrow, CircleAlert, PenTool } from 'lucide-react';
import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
import { GlassIconButton } from '@/components/ui/glass-icon-button';
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
@ -12,9 +12,8 @@ import ScriptLoading from './script-loading';
import { TaskObject } from '@/api/DTO/movieEdit';
import { Button, Tooltip } from 'antd';
import { downloadVideo, downloadAllVideos, getFirstFrame } from '@/utils/tools';
// 临时禁用视频编辑功能
// import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
// import { EditPoint as EditPointType } from './video-edit/types';
import { VideoEditOverlay } from './video-edit/VideoEditOverlay';
import { EditPoint as EditPointType } from './video-edit/types';
interface MediaViewerProps {
taskObject: TaskObject;
@ -35,9 +34,9 @@ interface MediaViewerProps {
onGotoCut: () => void;
isSmartChatBoxOpen: boolean;
onRetryVideo?: (video_id: string) => void;
// 临时禁用视频编辑功能
// enableVideoEdit?: boolean;
// onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
enableVideoEdit?: boolean;
onVideoEditDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
projectId?: string;
}
export const MediaViewer = React.memo(function MediaViewer({
@ -58,10 +57,10 @@ export const MediaViewer = React.memo(function MediaViewer({
showGotoCutButton,
onGotoCut,
isSmartChatBoxOpen,
onRetryVideo
// 临时禁用视频编辑功能
// enableVideoEdit = true,
// onVideoEditDescriptionSubmit
onRetryVideo,
enableVideoEdit = true,
onVideoEditDescriptionSubmit,
projectId
}: MediaViewerProps) {
const mainVideoRef = useRef<HTMLVideoElement>(null);
const finalVideoRef = useRef<HTMLVideoElement>(null);
@ -78,8 +77,7 @@ export const MediaViewer = React.memo(function MediaViewer({
const [toosBtnRight, setToodsBtnRight] = useState('1rem');
const [isLoadingDownloadBtn, setIsLoadingDownloadBtn] = useState(false);
const [isLoadingDownloadAllVideosBtn, setIsLoadingDownloadAllVideosBtn] = useState(false);
// 临时禁用视频编辑功能
// const [isVideoEditMode, setIsVideoEditMode] = useState(false);
const [isVideoEditMode, setIsVideoEditMode] = useState(false);
useEffect(() => {
if (isSmartChatBoxOpen) {
@ -506,11 +504,10 @@ export const MediaViewer = React.memo(function MediaViewer({
}}
/>
{/* 临时禁用视频编辑功能 */}
{/* 视频编辑覆盖层 */}
{/*enableVideoEdit && isVideoEditMode && (
{enableVideoEdit && isVideoEditMode && (
<VideoEditOverlay
projectId={taskObject.project_id || ''}
projectId={projectId || ''}
userId={JSON.parse(localStorage.getItem("currentUser") || '{}').id || 0}
currentVideo={{
id: taskObject.videos.data[currentSketchIndex].video_id,
@ -522,16 +519,15 @@ export const MediaViewer = React.memo(function MediaViewer({
onDescriptionSubmit={onVideoEditDescriptionSubmit}
className="rounded-lg"
/>
)*/}
)}
</motion.div>
{/* 跳转剪辑按钮 */}
<div className="absolute top-4 right-4 z-[21] flex items-center gap-2 transition-right duration-100" style={{
right: toosBtnRight
}}>
{/* 临时禁用视频编辑功能 */}
{/* 视频编辑模式切换按钮 */}
{/*enableVideoEdit && (
{/* 视频编辑模式切换按钮 - 临时注释 */}
{/* {enableVideoEdit && (
<Tooltip placement="top" title={isVideoEditMode ? "Exit edit mode" : "Enter edit mode"}>
<GlassIconButton
icon={PenTool}
@ -540,7 +536,7 @@ export const MediaViewer = React.memo(function MediaViewer({
className={isVideoEditMode ? 'bg-blue-500/20 border-blue-500/50' : ''}
/>
</Tooltip>
)*/}
)} */}
{/* 添加到chat去编辑 按钮 */}
<Tooltip placement="top" title="Edit video with chat">
<GlassIconButton icon={MessageCircleMore} size='sm' onClick={() => {

View File

@ -69,7 +69,7 @@ function calculateCurvePath({
export function calculateInputPosition(
editPointPosition: { x: number; y: number },
containerSize: { width: number; height: number },
inputBoxSize: { width: number; height: number } = { width: 200, height: 80 }
inputBoxSize: { width: number; height: number } = { width: 300, height: 50 }
): InputBoxPosition {
const { x: pointX, y: pointY } = editPointPosition;
const { width: containerWidth, height: containerHeight } = containerSize;
@ -151,26 +151,79 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
animated = true
}) => {
const {
color = 'rgba(255, 255, 255, 0.8)',
color = 'rgba(255, 255, 255, 0.9)', // White color to match the reference image
strokeWidth = 2,
dashArray = '5,5'
dashArray = '8,4' // Dashed line to match the reference image
} = style;
// 计算路径
const path = useMemo(() =>
// 计算箭头几何参数
const arrowSize = 8;
const arrowHalfHeight = 4;
// 计算连接方向和角度
const connectionVector = useMemo(() => {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const length = Math.sqrt(dx * dx + dy * dy);
return {
dx: dx / length,
dy: dy / length,
angle: Math.atan2(dy, dx)
};
}, [startPoint, endPoint]);
// 计算箭头的正确位置和线条终点
const arrowGeometry = useMemo(() => {
const { dx, dy, angle } = connectionVector;
// 箭头尖端位置原endPoint
const arrowTip = { x: endPoint.x, y: endPoint.y };
// 箭头底部中心点(线条应该连接到这里)
const arrowBase = {
x: endPoint.x - dx * arrowSize,
y: endPoint.y - dy * arrowSize
};
// 计算箭头三角形的三个顶点
const perpX = -dy; // 垂直向量X
const perpY = dx; // 垂直向量Y
const arrowPoints = [
arrowTip, // 尖端
{
x: arrowBase.x + perpX * arrowHalfHeight,
y: arrowBase.y + perpY * arrowHalfHeight
},
{
x: arrowBase.x - perpX * arrowHalfHeight,
y: arrowBase.y - perpY * arrowHalfHeight
}
];
return {
tip: arrowTip,
base: arrowBase,
points: arrowPoints,
angle
};
}, [endPoint, connectionVector, arrowSize, arrowHalfHeight]);
// 计算路径(线条终止于箭头底部中心)
const path = useMemo(() =>
calculateCurvePath({
start: startPoint,
end: endPoint,
end: arrowGeometry.base, // 连接到箭头底部中心而不是尖端
containerSize,
curvature
}), [startPoint, endPoint, containerSize, curvature]);
}), [startPoint, arrowGeometry.base, containerSize, curvature]);
// 计算路径长度用于动画
const pathLength = useMemo(() => {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const dx = arrowGeometry.base.x - startPoint.x;
const dy = arrowGeometry.base.y - startPoint.y;
return Math.sqrt(dx * dx + dy * dy) * 1.2; // 弧线比直线长约20%
}, [startPoint, endPoint]);
}, [startPoint, arrowGeometry.base]);
return (
<svg
@ -179,7 +232,7 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
height={containerSize.height}
style={{ zIndex: 10 }}
>
{/* 连接线路径 */}
{/* Curved dashed line - properly aligned to arrow base center */}
<motion.path
d={path}
fill="none"
@ -201,15 +254,13 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
opacity: { duration: 0.3 }
} : {}}
style={{
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))'
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
}}
/>
{/* 连接线末端的小圆点 */}
<motion.circle
cx={endPoint.x}
cy={endPoint.y}
r={3}
{/* Properly aligned arrow head with geometric precision */}
<motion.polygon
points={arrowGeometry.points.map(p => `${p.x},${p.y}`).join(' ')}
fill={color}
initial={animated ? {
scale: 0,
@ -227,32 +278,32 @@ export const EditConnection: React.FC<EditConnectionProps> = ({
damping: 25
} : {}}
style={{
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
}}
/>
{/* 动画流动效果(可选) */}
{animated && (
<motion.circle
r={2}
fill="rgba(255, 255, 255, 0.9)"
initial={{ opacity: 0 }}
animate={{
opacity: [0, 1, 0],
offsetDistance: ["0%", "100%"]
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "linear",
delay: 0.6
}}
style={{
offsetPath: `path('${path}')`,
offsetRotate: "0deg"
}}
/>
{/* Debug visualization (remove in production) */}
{process.env.NODE_ENV === 'development' && (
<>
{/* Arrow base center point */}
<circle
cx={arrowGeometry.base.x}
cy={arrowGeometry.base.y}
r={1}
fill="red"
opacity={0.5}
/>
{/* Arrow tip point */}
<circle
cx={arrowGeometry.tip.x}
cy={arrowGeometry.tip.y}
r={1}
fill="blue"
opacity={0.5}
/>
</>
)}
</svg>
);
};

View File

@ -101,7 +101,7 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
<AnimatePresence>
{editPoint.description && editPoint.status !== EditPointStatus.PENDING && (
<>
{/* 连接线 */}
{/* White dashed connection line to match reference image */}
<motion.svg
className="absolute pointer-events-none"
style={{
@ -118,30 +118,41 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
>
<motion.path
d={connectionPath}
stroke={statusColor}
stroke="rgba(255, 255, 255, 0.9)"
strokeWidth={2}
fill="none"
strokeDasharray="4,4"
strokeDasharray="8,4"
strokeLinecap="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={{
pathLength: 1,
opacity: 0.8,
strokeDashoffset: [0, -8]
animate={{
pathLength: 1,
opacity: 1
}}
exit={{ pathLength: 0, opacity: 0 }}
transition={{
pathLength: { duration: 0.8, ease: "easeOut" },
opacity: { duration: 0.5 },
strokeDashoffset: {
duration: 2,
repeat: Infinity,
ease: "linear"
}
opacity: { duration: 0.5 }
}}
style={{
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
}}
/>
{/* Arrow head */}
<motion.polygon
points={`${connectionEnd.x},${connectionEnd.y} ${connectionEnd.x-8},${connectionEnd.y-4} ${connectionEnd.x-8},${connectionEnd.y+4}`}
fill="rgba(255, 255, 255, 0.9)"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ delay: 0.4, duration: 0.3 }}
style={{
filter: 'drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.8))'
}}
/>
</motion.svg>
{/* 描述内容框 */}
{/* Consistent white text display matching EditInput component */}
<motion.div
className="absolute cursor-pointer group"
data-edit-description="true"
@ -149,23 +160,21 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
left: position.x,
top: position.y,
zIndex: 25,
maxWidth: '300px',
minWidth: '200px'
}}
initial={{
opacity: 0,
scale: 0.8,
y: -10
initial={{
opacity: 0,
scale: 0.8,
y: -10
}}
animate={{
opacity: 1,
scale: 1,
y: 0
animate={{
opacity: 1,
scale: 1,
y: 0
}}
exit={{
opacity: 0,
scale: 0.8,
y: -10
exit={{
opacity: 0,
scale: 0.8,
y: -10
}}
transition={{
type: "spring",
@ -174,98 +183,67 @@ export const EditDescription: React.FC<EditDescriptionProps> = ({
duration: 0.4
}}
onClick={() => onClick?.(editPoint)}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{/* 玻璃态背景 */}
<div className="relative bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-lg shadow-lg border border-white/20 dark:border-gray-700/30 overflow-hidden">
{/* 状态指示条 */}
<div
className="h-1 w-full"
style={{ backgroundColor: statusColor }}
/>
{/* 内容区域 */}
<div className="p-3">
{/* 状态标签 */}
{statusText && (
<div className="flex items-center gap-2 mb-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
/>
<span
className="text-xs font-medium"
style={{ color: statusColor }}
>
{statusText}
</span>
</div>
)}
{/* 描述文本 */}
<div className="text-sm text-gray-800 dark:text-gray-200 leading-relaxed">
{editPoint.description}
</div>
{/* 时间戳 */}
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2 flex items-center justify-between">
<span>
{Math.floor(editPoint.timestamp)}s
</span>
<span>
{new Date(editPoint.updatedAt).toLocaleTimeString()}
</span>
</div>
{/* White text display with exact same styling as EditInput */}
<div className="flex items-center">
<div
className="text-white font-bold text-lg tracking-wide uppercase"
style={{
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.8)',
fontFamily: 'Arial, sans-serif',
letterSpacing: '0.1em'
}}
>
{editPoint.description}
</div>
{/* 悬停时显示的操作按钮 */}
<motion.div
className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100"
initial={{ opacity: 0 }}
animate={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{/* Interactive edit/delete buttons on hover */}
<div className="ml-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-2">
{onEdit && (
<button
className="w-6 h-6 rounded-full bg-blue-500/80 hover:bg-blue-500 text-white text-xs flex items-center justify-center transition-colors"
<motion.button
onClick={(e) => {
e.stopPropagation();
onEdit(editPoint.id);
}}
title="编辑"
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm flex items-center justify-center text-white text-xs transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="Edit description"
>
</button>
</motion.button>
)}
{onDelete && (
<button
className="w-6 h-6 rounded-full bg-red-500/80 hover:bg-red-500 text-white text-xs flex items-center justify-center transition-colors"
<motion.button
onClick={(e) => {
e.stopPropagation();
onDelete(editPoint.id);
}}
title="删除"
className="w-6 h-6 rounded-full bg-red-500/20 hover:bg-red-500/30 backdrop-blur-sm flex items-center justify-center text-white text-xs transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="Delete edit point"
>
🗑
</button>
</motion.button>
)}
</motion.div>
{/* 装饰性光效 */}
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent pointer-events-none" />
</div>
</div>
{/* 连接点指示器 */}
<div
className="absolute w-2 h-2 rounded-full border-2 border-white shadow-sm"
style={{
backgroundColor: statusColor,
left: connectionEnd.x - position.x - 4,
top: connectionEnd.y - position.y - 4,
}}
/>
{/* Status indicator for processing states */}
{editPoint.status === EditPointStatus.PROCESSING && (
<div className="mt-1 flex items-center">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse mr-2"></div>
<span className="text-white/70 text-xs uppercase tracking-wide">Processing...</span>
</div>
)}
{editPoint.status === EditPointStatus.FAILED && (
<div className="mt-1 flex items-center">
<div className="w-2 h-2 bg-red-400 rounded-full mr-2"></div>
<span className="text-white/70 text-xs uppercase tracking-wide">Failed - Click to retry</span>
</div>
)}
</motion.div>
</>
)}

View File

@ -37,11 +37,10 @@ export const EditInput: React.FC<EditInputProps> = ({
isSubmitting = false,
onSubmit,
onCancel,
size = { width: 280, height: 120 },
placeholder = "Describe your edit request..."
size = { width: 300, height: 50 },
placeholder = "Describe your edit..."
}) => {
const [description, setDescription] = useState(editPoint.description || '');
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -116,7 +115,6 @@ export const EditInput: React.FC<EditInputProps> = ({
style={{
left: position.x,
top: position.y,
width: size.width,
}}
initial={{
opacity: 0,
@ -140,105 +138,33 @@ export const EditInput: React.FC<EditInputProps> = ({
duration: 0.3
}}
>
{/* 玻璃态背景容器 */}
<div className="bg-black/60 backdrop-blur-md rounded-lg border border-white/20 shadow-2xl overflow-hidden">
{/* 头部 */}
<div className="px-3 py-2 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
<span className="text-xs text-white/80 font-medium">
Edit Request
</span>
<span className="text-xs text-white/50">
{Math.floor(editPoint.timestamp)}s
</span>
</div>
<button
onClick={onCancel}
className="w-5 h-5 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white/70 hover:text-white transition-colors"
disabled={isSubmitting}
>
<X size={12} />
</button>
</div>
{/* Input interface - focused on input functionality only */}
<div className="flex items-center bg-white/90 backdrop-blur-sm rounded-lg px-3 py-2 shadow-lg">
<input
ref={textareaRef as any}
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isSubmitting}
className="flex-1 bg-transparent text-gray-800 placeholder-gray-400 text-sm border-none outline-none min-w-[200px]"
autoFocus
/>
{/* 输入区域 */}
<div className="p-3">
<textarea
ref={textareaRef}
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
disabled={isSubmitting}
className="w-full bg-transparent text-white placeholder-white/40 text-sm resize-none border-none outline-none min-h-[60px] max-h-[120px]"
style={{ lineHeight: '1.4' }}
/>
</div>
{/* 底部操作区 */}
<div className="px-3 py-2 border-t border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-white/50">
<span>Ctrl+Enter to submit</span>
<span></span>
<span>Esc to cancel</span>
</div>
<div className="flex items-center gap-2">
{/* 字符计数 */}
<span className={`text-xs ${
description.length > 500 ? 'text-red-400' : 'text-white/50'
}`}>
{description.length}/500
</span>
{/* 提交按钮 */}
<motion.button
onClick={handleSubmit}
disabled={!description.trim() || isSubmitting || description.length > 500}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-500 disabled:cursor-not-allowed text-white text-xs rounded-md transition-colors"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{isSubmitting ? (
<>
<Loader2 size={12} className="animate-spin" />
<span>Submitting...</span>
</>
) : (
<>
<Send size={12} />
<span>Submit</span>
</>
)}
</motion.button>
</div>
</div>
{/* 聚焦时的发光边框 */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 rounded-lg border-2 border-blue-400/50 pointer-events-none"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>
{/* Submit button */}
<button
onClick={handleSubmit}
disabled={!description.trim() || isSubmitting}
className="ml-2 w-8 h-8 rounded-full bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white flex items-center justify-center flex-shrink-0"
>
{isSubmitting ? (
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin"></div>
) : (
<span className="text-xs"></span>
)}
</AnimatePresence>
</button>
</div>
{/* 输入框指示箭头(可选) */}
<div
className="absolute w-3 h-3 bg-black/60 border-l border-t border-white/20 transform rotate-45"
style={{
left: 12,
top: -6,
}}
/>
</motion.div>
)}
</AnimatePresence>

View File

@ -71,44 +71,7 @@ export const EditPoint: React.FC<EditPointProps> = ({
onEdit(editPoint.id);
}, [onEdit, editPoint.id]);
// 根据状态获取颜色
const getStatusColor = useCallback(() => {
switch (editPoint.status) {
case EditPointStatus.PENDING:
return '#f59e0b'; // 黄色
case EditPointStatus.EDITED:
return '#10b981'; // 绿色
case EditPointStatus.PROCESSING:
return '#3b82f6'; // 蓝色
case EditPointStatus.COMPLETED:
return '#059669'; // 深绿色
case EditPointStatus.FAILED:
return '#ef4444'; // 红色
default:
return color;
}
}, [editPoint.status, color]);
// 根据状态获取图标
const getStatusIcon = useCallback(() => {
switch (editPoint.status) {
case EditPointStatus.PENDING:
return Edit3;
case EditPointStatus.EDITED:
return Check;
case EditPointStatus.PROCESSING:
return Loader2;
case EditPointStatus.COMPLETED:
return Check;
case EditPointStatus.FAILED:
return X;
default:
return Edit3;
}
}, [editPoint.status]);
const StatusIcon = getStatusIcon();
const statusColor = getStatusColor();
// Simplified for the image design - just use blue color
return (
<motion.div
@ -119,7 +82,7 @@ export const EditPoint: React.FC<EditPointProps> = ({
top: absolutePosition.y - size / 2,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
animate={{ scale: 1, opacity: 0 }} // Make invisible to match reference image
exit={{ scale: 0, opacity: 0 }}
transition={{
type: "spring",
@ -129,136 +92,17 @@ export const EditPoint: React.FC<EditPointProps> = ({
}}
onClick={handleClick}
>
{/* 脉冲动画背景 */}
<AnimatePresence>
{(editPoint.status === EditPointStatus.PENDING || isSelected) && (
<motion.div
className="absolute inset-0 rounded-full"
style={{
backgroundColor: pulseColor,
width: size * 3,
height: size * 3,
left: -size,
top: -size,
}}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [1, 1.5, 1],
opacity: [0.6, 0.2, 0.6],
}}
exit={{ scale: 0, opacity: 0 }}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut"
}}
/>
)}
</AnimatePresence>
{/* 主编辑点 */}
{/* Invisible edit point - just for click handling */}
<motion.div
className="relative rounded-full flex items-center justify-center shadow-lg backdrop-blur-sm"
className="relative rounded-full"
style={{
width: size,
height: size,
backgroundColor: statusColor,
border: `2px solid rgba(255, 255, 255, 0.3)`,
width: size * 2, // Larger click area
height: size * 2,
backgroundColor: 'transparent', // Invisible
}}
whileHover={{ scale: 1.2 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
animate={editPoint.status === EditPointStatus.PROCESSING ? {
rotate: 360,
transition: { duration: 1, repeat: Infinity, ease: "linear" }
} : {}}
>
<StatusIcon
size={size * 0.5}
color="white"
className={editPoint.status === EditPointStatus.PROCESSING ? "animate-spin" : ""}
/>
</motion.div>
{/* 选中状态的操作按钮 */}
<AnimatePresence>
{isSelected && editPoint.status !== EditPointStatus.PROCESSING && (
<motion.div
className="absolute flex gap-1"
style={{
left: size + 8,
top: -4,
}}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.2 }}
>
{/* 编辑按钮 */}
<motion.button
className="w-6 h-6 rounded-full bg-blue-500/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-blue-600/80 transition-colors"
onClick={handleEdit}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="编辑描述"
>
<Edit3 size={12} />
</motion.button>
{/* 删除按钮 */}
<motion.button
className="w-6 h-6 rounded-full bg-red-500/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-red-600/80 transition-colors"
onClick={handleDelete}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="删除编辑点"
>
<X size={12} />
</motion.button>
</motion.div>
)}
</AnimatePresence>
{/* 状态提示文本 */}
<AnimatePresence>
{isSelected && editPoint.description && (
<motion.div
className="absolute whitespace-nowrap text-xs text-white bg-black/60 backdrop-blur-sm rounded px-2 py-1 pointer-events-none"
style={{
left: size + 8,
top: size + 8,
maxWidth: 200,
}}
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
>
{editPoint.description.length > 50
? `${editPoint.description.substring(0, 50)}...`
: editPoint.description
}
</motion.div>
)}
</AnimatePresence>
{/* 时间戳显示 */}
<AnimatePresence>
{isSelected && (
<motion.div
className="absolute text-xs text-white/70 bg-black/40 backdrop-blur-sm rounded px-1 py-0.5 pointer-events-none"
style={{
left: -20,
top: size + 8,
}}
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
>
{Math.floor(editPoint.timestamp)}s
</motion.div>
)}
</AnimatePresence>
/>
</motion.div>
);
};

View File

@ -275,7 +275,8 @@ export const VideoEditOverlay: React.FC<VideoEditOverlayProps> = ({
<AnimatePresence>
{editPoints.map(editPoint => {
const elementPosition = elementPositions[editPoint.id];
// 只显示已提交且有描述的编辑点
// 只显示已提交且有描述的编辑点,且不在输入模式
if (
!editPoint.description ||
editPoint.description.trim() === '' ||

View File

@ -18,20 +18,20 @@ import { createEditPoint, updateEditPoint, deleteEditPoint, getEditPoints } from
import { debounce } from './utils';
/**
*
* -
*/
const DEFAULT_CONFIG: VideoEditConfig = {
enabled: true,
maxEditPoints: 10,
connectionStyle: {
color: 'rgba(255, 255, 255, 0.8)',
color: 'rgba(255, 255, 255, 0.9)', // 白色连接线匹配参考图像
strokeWidth: 2,
dashArray: '5,5'
dashArray: '8,4' // 虚线样式匹配参考图像
},
pointStyle: {
size: 12,
color: '#3b82f6',
pulseColor: 'rgba(59, 130, 246, 0.3)'
color: 'transparent', // 透明编辑点匹配参考图像
pulseColor: 'transparent' // 透明脉冲动画
}
};