Обрезка видео
This commit is contained in:
@@ -45,8 +45,13 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
const [bgColor, setBgColor] = useState('#1e1e2e');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text'>('none');
|
||||
const [privacy, setPrivacy] = useState<'all' | 'contacts' | 'selected'>('all');
|
||||
const [startTime, setStartTime] = useState(0);
|
||||
const [endTime, setEndTime] = useState(0);
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text' | 'trim'>('none');
|
||||
const [paused, setPaused] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [textObjects, setTextObjects] = useState<TextObject[]>([]);
|
||||
const [selectedTextId, setSelectedTextId] = useState<number | null>(null);
|
||||
@@ -89,7 +94,22 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
const url = URL.createObjectURL(file);
|
||||
setMediaPreview(url);
|
||||
setCroppedPreview(null);
|
||||
setTool(file.type.startsWith('video/') ? 'none' : 'crop');
|
||||
|
||||
if (file.type.startsWith('video/')) {
|
||||
const v = document.createElement('video');
|
||||
v.src = url;
|
||||
v.onloadedmetadata = () => {
|
||||
setVideoDuration(v.duration);
|
||||
setEndTime(v.duration);
|
||||
if (v.duration > 120) {
|
||||
setTool('trim');
|
||||
} else {
|
||||
setTool('none');
|
||||
}
|
||||
};
|
||||
} else {
|
||||
setTool('crop');
|
||||
}
|
||||
};
|
||||
|
||||
const removeMedia = () => {
|
||||
@@ -171,27 +191,24 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
let content = '';
|
||||
|
||||
if (isVideo && mediaFile) {
|
||||
// Handle Video: upload original file
|
||||
const res = await StoryApi.uploadVideoToStory(mediaFile);
|
||||
url = res.url;
|
||||
// Serialize overlays as metadata
|
||||
content = JSON.stringify({
|
||||
stickers: stickers.map(s => ({ ...s, emoji: s.emoji })), // Ensure emoji is string
|
||||
textObjects: textObjects.map(t => ({ ...t }))
|
||||
});
|
||||
const meta = {
|
||||
stickers,
|
||||
textObjects,
|
||||
trim: startTime > 0 || endTime < videoDuration ? { start: startTime, end: endTime } : null
|
||||
};
|
||||
content = JSON.stringify(meta);
|
||||
} else {
|
||||
// Handle Image/Text: bake into canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = TARGET_W;
|
||||
canvas.height = TARGET_H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas init failed');
|
||||
|
||||
// 1. BG Color
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, TARGET_W, TARGET_H);
|
||||
|
||||
// 2. Base Image Layer
|
||||
const baseSrc = croppedPreview || mediaPreview;
|
||||
if (baseSrc && (mediaFile?.type.startsWith('image/') || !mediaFile)) {
|
||||
const img = await loadImage(baseSrc);
|
||||
@@ -206,12 +223,10 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 3. Brush Layer
|
||||
if (canvasRef.current) {
|
||||
ctx.drawImage(canvasRef.current, 0, 0, TARGET_W, TARGET_H);
|
||||
}
|
||||
|
||||
// 4. Overlays
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
@@ -271,10 +286,16 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{mediaFile?.type.startsWith('video/') && (
|
||||
<button onClick={() => setIsMuted(!isMuted)} className={`p-3 rounded-xl border transition-all ${isMuted ? 'bg-red-500/10 border-red-500/40 text-red-500' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={isMuted ? "Включить звук" : "Отключить звук"}>
|
||||
{isMuted ? <Wind size={18} /> : <Droplets size={18} />}
|
||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{isMuted ? 'Без звука' : 'Со звуком'}</span>
|
||||
</button>
|
||||
<>
|
||||
<button onClick={() => setTool('trim')} className={`p-3 rounded-xl border transition-all ${tool === 'trim' ? 'bg-primary/10 border-primary/40 text-primary' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title="Обрезать видео">
|
||||
<span className="material-symbols-outlined">content_cut</span>
|
||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">Обрезать</span>
|
||||
</button>
|
||||
<button onClick={() => setIsMuted(!isMuted)} className={`p-3 rounded-xl border transition-all ${isMuted ? 'bg-red-500/10 border-red-500/40 text-red-500' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={isMuted ? "Включить звук" : "Отключить звук"}>
|
||||
{isMuted ? <Wind size={18} /> : <Droplets size={18} />}
|
||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{isMuted ? 'Без звука' : 'Со звуком'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); setIsMuted(false); }} className="px-6 py-3 text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white border border-white/5 rounded-xl hover:bg-white/5 transition-all">Сброс</button>
|
||||
<button onClick={handlePublish} disabled={isUploading} className="px-8 py-3 bg-primary text-on-primary rounded-xl font-black text-xs uppercase tracking-widest hover:scale-105 active:scale-95 disabled:opacity-50 transition-all shadow-xl shadow-primary/20">
|
||||
@@ -291,7 +312,24 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<div className="absolute inset-0" style={{ filter: getFilterCss() }}>
|
||||
{mediaFile?.type.startsWith('video/') ? (
|
||||
<div className="w-full h-full flex items-center justify-center" style={{ background: bgColor }}>
|
||||
<video src={mediaPreview!} className="w-full h-full object-contain" autoPlay muted={isMuted} loop />
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={mediaPreview!}
|
||||
className="w-full h-full object-contain"
|
||||
autoPlay={tool !== 'trim'}
|
||||
muted={isMuted}
|
||||
loop={tool !== 'trim'}
|
||||
onTimeUpdate={() => {
|
||||
if (tool === 'trim' && videoRef.current) {
|
||||
if (videoRef.current.currentTime > endTime) {
|
||||
videoRef.current.currentTime = startTime;
|
||||
}
|
||||
if (videoRef.current.currentTime < startTime) {
|
||||
videoRef.current.currentTime = startTime;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img src={croppedPreview || mediaPreview!} className="w-full h-full object-cover" alt="base" />
|
||||
@@ -426,6 +464,105 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{tool === 'trim' && (
|
||||
<motion.div key="trim-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-8 rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-white text-[10px] font-black uppercase tracking-widest flex items-center gap-2">
|
||||
<Scissors size={14} className="text-primary" />
|
||||
Обрезка видео
|
||||
</h3>
|
||||
<div className="text-[10px] font-bold text-zinc-500 bg-white/5 px-2 py-1 rounded">
|
||||
{(endTime - startTime).toFixed(1)}с / {videoDuration.toFixed(1)}с
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoDuration > 120 && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-[10px] text-red-400 font-bold uppercase tracking-wider leading-relaxed">
|
||||
<span className="material-symbols-outlined text-sm">warning</span>
|
||||
Видео слишком длинное! Обязательно обрежьте до 2 минут (120с).
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-[9px] uppercase tracking-widest text-zinc-500 font-black">
|
||||
<span>Начало</span>
|
||||
<span className="text-white">{startTime.toFixed(1)}с</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, endTime - 1)}
|
||||
step={0.1}
|
||||
value={startTime}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setStartTime(val);
|
||||
if (videoRef.current) videoRef.current.currentTime = val;
|
||||
}}
|
||||
className="w-full h-1 bg-white/5 rounded-full appearance-none accent-primary cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-[9px] uppercase tracking-widest text-zinc-500 font-black">
|
||||
<span>Конец</span>
|
||||
<span className="text-white">{endTime.toFixed(1)}с</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={startTime + 1}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={endTime}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setEndTime(val);
|
||||
if (videoRef.current) videoRef.current.currentTime = val;
|
||||
}}
|
||||
className="w-full h-1 bg-white/5 rounded-full appearance-none accent-primary cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-6 border-t border-white/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (videoRef.current) {
|
||||
if (videoRef.current.paused) videoRef.current.play();
|
||||
else videoRef.current.pause();
|
||||
setPaused(videoRef.current.paused);
|
||||
}
|
||||
}}
|
||||
className="w-14 h-14 rounded-2xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-white transition-all border border-white/5"
|
||||
>
|
||||
{paused ? <Plus size={24} className="rotate-45" /> : <Minus size={24} />} {/* Using Plus/Minus as fallback icons since play/pause might be missing or I'll use standard symbols */}
|
||||
<span className="material-symbols-outlined">{paused ? 'play_arrow' : 'pause'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (videoRef.current) videoRef.current.currentTime = startTime; }}
|
||||
className="text-[10px] uppercase font-black tracking-widest text-zinc-500 hover:text-white"
|
||||
>
|
||||
В начало
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (videoDuration > 120 && (endTime - startTime) > 120) {
|
||||
return;
|
||||
}
|
||||
setTool('none');
|
||||
}}
|
||||
disabled={videoDuration > 120 && (endTime - startTime) > 120}
|
||||
className="px-8 py-4 bg-white text-black rounded-2xl text-[10px] font-black uppercase tracking-widest hover:scale-105 active:scale-95 transition-all shadow-xl shadow-white/10 disabled:opacity-30"
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!mediaPreview && tool === 'none' && (
|
||||
<motion.div key="bg-tool" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="p-10 rounded-[2.5rem] bg-[#121212] flex flex-col items-center gap-8 border border-white/5 shadow-2xl">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
|
||||
@@ -78,14 +78,14 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
const metadata = useMemo(() => {
|
||||
if (!currentStory?.content) return null;
|
||||
try {
|
||||
const data = JSON.parse(currentStory.content);
|
||||
if (data.stickers || data.textObjects) return data;
|
||||
return JSON.parse(currentStory.content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}, [currentStory?.content]);
|
||||
|
||||
const trim = useMemo(() => metadata?.trim || null, [metadata]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.user.id === user?.id) {
|
||||
setIsMuted(false);
|
||||
@@ -166,6 +166,10 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
const video = videoRef.current;
|
||||
if (!video || !isVideo) return;
|
||||
|
||||
if (trim && Math.abs(video.currentTime - trim.start) > 0.5 && video.currentTime < trim.start) {
|
||||
video.currentTime = trim.start;
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
video.pause();
|
||||
} else {
|
||||
@@ -174,12 +178,24 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (video.duration) {
|
||||
setProgress((video.currentTime / video.duration) * 100);
|
||||
if (trim) {
|
||||
const start = trim.start || 0;
|
||||
const end = trim.end || video.duration;
|
||||
const current = video.currentTime;
|
||||
|
||||
setProgress(Math.min(100, Math.max(0, ((current - start) / (end - start)) * 100)));
|
||||
|
||||
if (current >= end) {
|
||||
goNext();
|
||||
}
|
||||
} else {
|
||||
setProgress((video.currentTime / video.duration) * 100);
|
||||
}
|
||||
}
|
||||
}, TICK);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isVideo, paused, storyIndex, userIndex]);
|
||||
}, [isVideo, paused, storyIndex, userIndex, trim, goNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStory || currentUser.user.id === user?.id) return;
|
||||
|
||||
Reference in New Issue
Block a user