Обрезка видео
This commit is contained in:
@@ -45,8 +45,13 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
const [bgColor, setBgColor] = useState('#1e1e2e');
|
const [bgColor, setBgColor] = useState('#1e1e2e');
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isMuted, setIsMuted] = 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 [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 [textObjects, setTextObjects] = useState<TextObject[]>([]);
|
||||||
const [selectedTextId, setSelectedTextId] = useState<number | null>(null);
|
const [selectedTextId, setSelectedTextId] = useState<number | null>(null);
|
||||||
@@ -89,7 +94,22 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
setMediaPreview(url);
|
setMediaPreview(url);
|
||||||
setCroppedPreview(null);
|
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 = () => {
|
const removeMedia = () => {
|
||||||
@@ -171,27 +191,24 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
let content = '';
|
let content = '';
|
||||||
|
|
||||||
if (isVideo && mediaFile) {
|
if (isVideo && mediaFile) {
|
||||||
// Handle Video: upload original file
|
|
||||||
const res = await StoryApi.uploadVideoToStory(mediaFile);
|
const res = await StoryApi.uploadVideoToStory(mediaFile);
|
||||||
url = res.url;
|
url = res.url;
|
||||||
// Serialize overlays as metadata
|
const meta = {
|
||||||
content = JSON.stringify({
|
stickers,
|
||||||
stickers: stickers.map(s => ({ ...s, emoji: s.emoji })), // Ensure emoji is string
|
textObjects,
|
||||||
textObjects: textObjects.map(t => ({ ...t }))
|
trim: startTime > 0 || endTime < videoDuration ? { start: startTime, end: endTime } : null
|
||||||
});
|
};
|
||||||
|
content = JSON.stringify(meta);
|
||||||
} else {
|
} else {
|
||||||
// Handle Image/Text: bake into canvas
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = TARGET_W;
|
canvas.width = TARGET_W;
|
||||||
canvas.height = TARGET_H;
|
canvas.height = TARGET_H;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) throw new Error('Canvas init failed');
|
if (!ctx) throw new Error('Canvas init failed');
|
||||||
|
|
||||||
// 1. BG Color
|
|
||||||
ctx.fillStyle = bgColor;
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillRect(0, 0, TARGET_W, TARGET_H);
|
ctx.fillRect(0, 0, TARGET_W, TARGET_H);
|
||||||
|
|
||||||
// 2. Base Image Layer
|
|
||||||
const baseSrc = croppedPreview || mediaPreview;
|
const baseSrc = croppedPreview || mediaPreview;
|
||||||
if (baseSrc && (mediaFile?.type.startsWith('image/') || !mediaFile)) {
|
if (baseSrc && (mediaFile?.type.startsWith('image/') || !mediaFile)) {
|
||||||
const img = await loadImage(baseSrc);
|
const img = await loadImage(baseSrc);
|
||||||
@@ -206,12 +223,10 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Brush Layer
|
|
||||||
if (canvasRef.current) {
|
if (canvasRef.current) {
|
||||||
ctx.drawImage(canvasRef.current, 0, 0, TARGET_W, TARGET_H);
|
ctx.drawImage(canvasRef.current, 0, 0, TARGET_W, TARGET_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Overlays
|
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
@@ -271,10 +286,16 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{mediaFile?.type.startsWith('video/') && (
|
{mediaFile?.type.startsWith('video/') && (
|
||||||
|
<>
|
||||||
|
<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 ? "Включить звук" : "Отключить звук"}>
|
<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} />}
|
{isMuted ? <Wind size={18} /> : <Droplets size={18} />}
|
||||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{isMuted ? 'Без звука' : 'Со звуком'}</span>
|
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{isMuted ? 'Без звука' : 'Со звуком'}</span>
|
||||||
</button>
|
</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={() => { 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">
|
<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() }}>
|
<div className="absolute inset-0" style={{ filter: getFilterCss() }}>
|
||||||
{mediaFile?.type.startsWith('video/') ? (
|
{mediaFile?.type.startsWith('video/') ? (
|
||||||
<div className="w-full h-full flex items-center justify-center" style={{ background: bgColor }}>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img src={croppedPreview || mediaPreview!} className="w-full h-full object-cover" alt="base" />
|
<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>
|
</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' && (
|
{!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">
|
<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">
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
|||||||
@@ -78,14 +78,14 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
const metadata = useMemo(() => {
|
const metadata = useMemo(() => {
|
||||||
if (!currentStory?.content) return null;
|
if (!currentStory?.content) return null;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(currentStory.content);
|
return JSON.parse(currentStory.content);
|
||||||
if (data.stickers || data.textObjects) return data;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}, [currentStory?.content]);
|
}, [currentStory?.content]);
|
||||||
|
|
||||||
|
const trim = useMemo(() => metadata?.trim || null, [metadata]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.user.id === user?.id) {
|
if (currentUser?.user.id === user?.id) {
|
||||||
setIsMuted(false);
|
setIsMuted(false);
|
||||||
@@ -166,6 +166,10 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video || !isVideo) return;
|
if (!video || !isVideo) return;
|
||||||
|
|
||||||
|
if (trim && Math.abs(video.currentTime - trim.start) > 0.5 && video.currentTime < trim.start) {
|
||||||
|
video.currentTime = trim.start;
|
||||||
|
}
|
||||||
|
|
||||||
if (paused) {
|
if (paused) {
|
||||||
video.pause();
|
video.pause();
|
||||||
} else {
|
} else {
|
||||||
@@ -174,12 +178,24 @@ export default function StoryViewer({ stories, initialUserIndex, initialStoryInd
|
|||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (video.duration) {
|
if (video.duration) {
|
||||||
|
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);
|
setProgress((video.currentTime / video.duration) * 100);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, TICK);
|
}, TICK);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isVideo, paused, storyIndex, userIndex]);
|
}, [isVideo, paused, storyIndex, userIndex, trim, goNext]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentStory || currentUser.user.id === user?.id) return;
|
if (!currentStory || currentUser.user.id === user?.id) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user