Обрезка видео

This commit is contained in:
Халимов Рустам
2026-04-03 21:32:02 +03:00
parent a04f04a448
commit 182bbe2ad8
2 changed files with 176 additions and 23 deletions

View File

@@ -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">

View File

@@ -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;