Initial commit — Bradly branding editor platform
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Music, Play, Pause, Clock, Loader2 } from 'lucide-react';
|
||||
import { useEditor } from '../../context/EditorContext';
|
||||
import { FileDropZone } from '../ui/FileDropZone';
|
||||
import { uploadMedia } from '../../utils/mediaUploader';
|
||||
import { useAudioPreview } from '../../hooks/useAudioPreview';
|
||||
import { getAudioDuration, formatDuration } from '../../utils/audioMetadata';
|
||||
import { AudioWaveformCanvas } from '../timeline/AudioWaveformCanvas';
|
||||
|
||||
interface AudioPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface AudioItem {
|
||||
src: string;
|
||||
name: string;
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel for adding audio files. Draggable to timeline.
|
||||
* Auto-routes to audio layers. Supports preview and waveform.
|
||||
*/
|
||||
export const AudioPanel: React.FC<AudioPanelProps> = ({ onClose }) => {
|
||||
const { designMD } = useEditor();
|
||||
const [localAudios, setLocalAudios] = useState<AudioItem[]>([]);
|
||||
const [brandDuration, setBrandDuration] = useState<number | null>(null);
|
||||
const preview = useAudioPreview();
|
||||
const [playingIdx, setPlayingIdx] = useState<number | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Load brand audio duration
|
||||
useEffect(() => {
|
||||
if (designMD.brandAudioUrl) {
|
||||
getAudioDuration(designMD.brandAudioUrl).then(d => setBrandDuration(d));
|
||||
}
|
||||
}, [designMD.brandAudioUrl]);
|
||||
|
||||
const handleUpload = useCallback(async (files: File[]) => {
|
||||
const audioFiles = files.filter(f => f.type.startsWith('audio/'));
|
||||
if (audioFiles.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const items: AudioItem[] = [];
|
||||
|
||||
for (const file of audioFiles) {
|
||||
const result = await uploadMedia(file);
|
||||
let duration: number | null = null;
|
||||
try {
|
||||
duration = await getAudioDuration(result.url);
|
||||
} catch {}
|
||||
items.push({ src: result.url, name: result.originalName, duration });
|
||||
}
|
||||
|
||||
setLocalAudios(prev => [...items, ...prev]);
|
||||
} catch (err) {
|
||||
console.error('Audio upload failed:', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTogglePreview = useCallback((src: string, idx: number) => {
|
||||
if (playingIdx === idx) {
|
||||
preview.pause();
|
||||
setPlayingIdx(null);
|
||||
} else {
|
||||
preview.setSrc(src);
|
||||
preview.play();
|
||||
setPlayingIdx(idx);
|
||||
}
|
||||
}, [playingIdx, preview]);
|
||||
|
||||
// Stop preview when panel closes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
preview.pause();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const allAudios = [...localAudios];
|
||||
const hasBrandAudio = !!designMD.brandAudioUrl;
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
|
||||
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Music size={14} className="text-violet-400" />
|
||||
Audio
|
||||
</h3>
|
||||
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-1 overflow-y-auto space-y-4">
|
||||
{/* Upload */}
|
||||
<FileDropZone
|
||||
accept="audio/*"
|
||||
multiple
|
||||
onFiles={handleUpload}
|
||||
label={isUploading ? 'Subiendo...' : "Subir audio"}
|
||||
sublabel={isUploading ? undefined : "MP3, WAV, OGG"}
|
||||
/>
|
||||
{isUploading && (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-violet-400">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span className="text-[10px] font-medium">Subiendo al servidor...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand Audio */}
|
||||
{hasBrandAudio && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Audio de Marca</span>
|
||||
<div
|
||||
className="flex flex-col gap-2 p-2.5 bg-neutral-800/50 border border-neutral-800 rounded-lg cursor-grab active:cursor-grabbing hover:border-violet-500/40 transition-colors group"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', designMD.brandAudioUrl!);
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'audio', src: designMD.brandAudioUrl }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleTogglePreview(designMD.brandAudioUrl!, -1); }}
|
||||
className="w-8 h-8 rounded-md bg-violet-600/20 border border-violet-500/30 flex items-center justify-center shrink-0 hover:bg-violet-600/40 transition-colors"
|
||||
title={playingIdx === -1 ? "Pausar Preview" : "Escuchar Preview"}
|
||||
>
|
||||
{playingIdx === -1 ? <Pause size={12} className="text-violet-300" /> : <Play size={12} className="text-violet-400 ml-0.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] font-medium text-white block truncate">Jingle de Marca</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] text-neutral-500">Arrastrar al timeline</span>
|
||||
{brandDuration !== null && (
|
||||
<span className="text-[9px] text-neutral-600 font-mono flex items-center gap-0.5">
|
||||
<Clock size={8} /> {formatDuration(brandDuration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mini Waveform */}
|
||||
<div className="bg-neutral-900/50 rounded overflow-hidden">
|
||||
<AudioWaveformCanvas
|
||||
src={designMD.brandAudioUrl!}
|
||||
width={220}
|
||||
height={24}
|
||||
color="rgba(139, 92, 246, 0.4)"
|
||||
resolution={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded Audios */}
|
||||
{localAudios.length > 0 && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Mis Audios</span>
|
||||
<div className="space-y-2">
|
||||
{localAudios.map((audio, i) => (
|
||||
<div
|
||||
key={`audio-${i}`}
|
||||
className="flex flex-col gap-2 p-2.5 bg-neutral-950/50 border border-neutral-800/60 rounded-lg cursor-grab active:cursor-grabbing hover:border-neutral-700 transition-colors group"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', audio.src);
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'audio',
|
||||
src: audio.src,
|
||||
fileName: audio.name,
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleTogglePreview(audio.src, i); }}
|
||||
className="w-8 h-8 rounded-md bg-neutral-800 flex items-center justify-center shrink-0 hover:bg-neutral-700 transition-colors"
|
||||
title={playingIdx === i ? "Pausar Preview" : "Escuchar Preview"}
|
||||
>
|
||||
{playingIdx === i ? <Pause size={12} className="text-violet-300" /> : <Play size={12} className="text-neutral-400 ml-0.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] font-medium text-neutral-300 block truncate" title={audio.name}>{audio.name}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] text-neutral-600">Arrastrar al timeline</span>
|
||||
{audio.duration !== null && (
|
||||
<span className="text-[9px] text-neutral-600 font-mono flex items-center gap-0.5">
|
||||
<Clock size={8} /> {formatDuration(audio.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mini Waveform */}
|
||||
<div className="bg-neutral-900/30 rounded overflow-hidden">
|
||||
<AudioWaveformCanvas
|
||||
src={audio.src}
|
||||
width={220}
|
||||
height={20}
|
||||
color="rgba(129, 140, 248, 0.35)"
|
||||
resolution={80}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!hasBrandAudio && localAudios.length === 0 && (
|
||||
<div className="text-center py-6 text-neutral-500">
|
||||
<Music size={28} className="mx-auto mb-2 opacity-40" />
|
||||
<p className="text-xs font-medium">Sin audio disponible</p>
|
||||
<p className="text-[10px] mt-1">Sube archivos de audio o configura el jingle de marca</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { X, Hexagon } from 'lucide-react';
|
||||
import { useEditor } from '../../context/EditorContext';
|
||||
import { TimelineElement } from '../../types';
|
||||
|
||||
interface ShapesPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ShapeDef {
|
||||
type: TimelineElement['shapeType'];
|
||||
label: string;
|
||||
svg: React.ReactNode;
|
||||
}
|
||||
|
||||
const SHAPES: ShapeDef[] = [
|
||||
{
|
||||
type: 'rectangle',
|
||||
label: 'Rectángulo',
|
||||
svg: (
|
||||
<svg viewBox="0 0 48 48" className="w-10 h-10">
|
||||
<rect x="4" y="8" width="40" height="32" rx="3" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'circle',
|
||||
label: 'Círculo',
|
||||
svg: (
|
||||
<svg viewBox="0 0 48 48" className="w-10 h-10">
|
||||
<circle cx="24" cy="24" r="20" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'triangle',
|
||||
label: 'Triángulo',
|
||||
svg: (
|
||||
<svg viewBox="0 0 48 48" className="w-10 h-10">
|
||||
<polygon points="24,4 44,44 4,44" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'star',
|
||||
label: 'Estrella',
|
||||
svg: (
|
||||
<svg viewBox="0 0 48 48" className="w-10 h-10">
|
||||
<polygon points="24,2 29,17 46,17 33,27 38,44 24,34 10,44 15,27 2,17 19,17" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
label: 'Línea',
|
||||
svg: (
|
||||
<svg viewBox="0 0 48 12" className="w-10 h-4">
|
||||
<line x1="4" y1="6" x2="44" y2="6" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'arrow',
|
||||
label: 'Flecha',
|
||||
svg: (
|
||||
<svg viewBox="0 0 48 24" className="w-10 h-5">
|
||||
<line x1="4" y1="12" x2="36" y2="12" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
<polygon points="32,4 46,12 32,20" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* ShapesPanel — Grid of basic shapes to insert into the canvas.
|
||||
*/
|
||||
export const ShapesPanel: React.FC<ShapesPanelProps> = ({ onClose }) => {
|
||||
const {
|
||||
layers, setLayers,
|
||||
activeLayerId, setActiveLayerId,
|
||||
setTimelineElements,
|
||||
setSelectedElementId,
|
||||
playerRef,
|
||||
durationInFrames,
|
||||
} = useEditor();
|
||||
|
||||
const addShape = useCallback((shapeDef: ShapeDef) => {
|
||||
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
|
||||
const newId = 'el-' + Date.now();
|
||||
|
||||
// Find or create a visual layer
|
||||
let targetLayerId = activeLayerId;
|
||||
const activeLayer = layers.find(l => l.id === activeLayerId);
|
||||
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
|
||||
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
|
||||
if (!visualLayer) {
|
||||
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
|
||||
setLayers(prev => [...prev, visualLayer!]);
|
||||
}
|
||||
targetLayerId = visualLayer.id;
|
||||
setActiveLayerId(targetLayerId);
|
||||
}
|
||||
|
||||
const newElement: TimelineElement = {
|
||||
id: newId,
|
||||
layerId: targetLayerId,
|
||||
type: 'shape',
|
||||
content: shapeDef.label,
|
||||
startFrame: currentFrame,
|
||||
endFrame: Math.min(durationInFrames, currentFrame + 100),
|
||||
x: 35,
|
||||
y: 35,
|
||||
width: 30,
|
||||
shapeType: shapeDef.type,
|
||||
shapeFill: '#ffffff',
|
||||
shapeStroke: 'none',
|
||||
shapeStrokeWidth: 0,
|
||||
shapeCornerRadius: 0,
|
||||
};
|
||||
|
||||
setTimelineElements(prev => [...prev, newElement]);
|
||||
setSelectedElementId(newId);
|
||||
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-neutral-950 border-r border-neutral-800 flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-800 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hexagon size={16} className="text-violet-400" />
|
||||
<h3 className="text-sm font-bold text-white">Formas</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Cerrar panel"
|
||||
className="p-1 rounded hover:bg-neutral-800 text-neutral-500 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shapes Grid */}
|
||||
<div className="p-4 flex-1 overflow-y-auto custom-scrollbar">
|
||||
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-3">Básicas</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SHAPES.map((shape) => (
|
||||
<button
|
||||
key={shape.type}
|
||||
onClick={() => addShape(shape)}
|
||||
title={`Insertar ${shape.label}`}
|
||||
className="flex flex-col items-center justify-center gap-1.5 p-3 rounded-xl border border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:text-violet-400 hover:border-violet-500/40 hover:bg-violet-500/5 transition-all group"
|
||||
>
|
||||
<div className="text-neutral-500 group-hover:text-violet-400 transition-colors">
|
||||
{shape.svg}
|
||||
</div>
|
||||
<span className="text-[9px] font-medium">{shape.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,407 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Search, Volume2, Plus, Loader2 } from 'lucide-react';
|
||||
import { useEditor } from '../../context/EditorContext';
|
||||
import { TimelineElement } from '../../types';
|
||||
|
||||
interface SfxCategory {
|
||||
name: string;
|
||||
emoji: string;
|
||||
effects: SoundEffect[];
|
||||
}
|
||||
|
||||
interface SoundEffect {
|
||||
name: string;
|
||||
description: string;
|
||||
durationSec: number;
|
||||
// These would be actual URLs in production — for now they are placeholders
|
||||
// that get generated/loaded on demand
|
||||
generator: 'tone' | 'noise' | 'click';
|
||||
frequency?: number;
|
||||
}
|
||||
|
||||
const SFX_CATEGORIES: SfxCategory[] = [
|
||||
{
|
||||
name: 'Transiciones',
|
||||
emoji: '🔄',
|
||||
effects: [
|
||||
{ name: 'Whoosh', description: 'Paso rápido', durationSec: 0.8, generator: 'noise' },
|
||||
{ name: 'Swoosh Suave', description: 'Movimiento suave', durationSec: 0.6, generator: 'noise' },
|
||||
{ name: 'Click', description: 'Click mecánico', durationSec: 0.2, generator: 'click' },
|
||||
{ name: 'Pop', description: 'Aparición', durationSec: 0.3, generator: 'click', frequency: 800 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'UI / Notificaciones',
|
||||
emoji: '🔔',
|
||||
effects: [
|
||||
{ name: 'Ding', description: 'Notificación', durationSec: 0.5, generator: 'tone', frequency: 880 },
|
||||
{ name: 'Beep', description: 'Alerta simple', durationSec: 0.3, generator: 'tone', frequency: 440 },
|
||||
{ name: 'Error', description: 'Error/rechazo', durationSec: 0.4, generator: 'tone', frequency: 220 },
|
||||
{ name: 'Success', description: 'Éxito/aprobado', durationSec: 0.6, generator: 'tone', frequency: 660 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Impacto',
|
||||
emoji: '💥',
|
||||
effects: [
|
||||
{ name: 'Boom', description: 'Impacto bajo', durationSec: 1.0, generator: 'noise' },
|
||||
{ name: 'Hit Suave', description: 'Golpe leve', durationSec: 0.4, generator: 'noise' },
|
||||
{ name: 'Drum Hit', description: 'Tambor', durationSec: 0.5, generator: 'tone', frequency: 80 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Ambientes',
|
||||
emoji: '🌊',
|
||||
effects: [
|
||||
{ name: 'Lluvia', description: 'Sonido de lluvia', durationSec: 3.0, generator: 'noise' },
|
||||
{ name: 'Viento', description: 'Brisa suave', durationSec: 3.0, generator: 'noise' },
|
||||
{ name: 'Estática', description: 'Ruido blanco', durationSec: 2.0, generator: 'noise' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a simple sound effect using Web Audio API and return as blob URL.
|
||||
*/
|
||||
function generateSfx(effect: SoundEffect): string {
|
||||
const ctx = new OfflineAudioContext(1, 44100 * effect.durationSec, 44100);
|
||||
|
||||
if (effect.generator === 'tone') {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = effect.frequency ?? 440;
|
||||
gain.gain.setValueAtTime(0.5, 0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(0);
|
||||
osc.stop(effect.durationSec);
|
||||
} else if (effect.generator === 'click') {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = effect.frequency ?? 1000;
|
||||
gain.gain.setValueAtTime(0.8, 0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec * 0.3);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(0);
|
||||
osc.stop(effect.durationSec);
|
||||
} else {
|
||||
// Noise generator
|
||||
const bufferSize = ctx.sampleRate * effect.durationSec;
|
||||
const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = noiseBuffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = noiseBuffer;
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.3, 0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, effect.durationSec * 0.9);
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
source.start(0);
|
||||
}
|
||||
|
||||
// OfflineAudioContext renders synchronously in terms of API but returns a promise
|
||||
// We'll create a placeholder and update async
|
||||
return ''; // Will be replaced
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SFX and return a blob URL asynchronously.
|
||||
*/
|
||||
async function generateSfxAsync(effect: SoundEffect): Promise<string> {
|
||||
const sampleRate = 44100;
|
||||
const duration = effect.durationSec;
|
||||
const ctx = new OfflineAudioContext(1, sampleRate * duration, sampleRate);
|
||||
|
||||
if (effect.generator === 'tone') {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = effect.frequency ?? 440;
|
||||
gain.gain.setValueAtTime(0.5, 0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, duration);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(0);
|
||||
osc.stop(duration);
|
||||
} else if (effect.generator === 'click') {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = effect.frequency ?? 1000;
|
||||
gain.gain.setValueAtTime(0.8, 0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, duration * 0.3);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(0);
|
||||
osc.stop(duration);
|
||||
} else {
|
||||
const bufferSize = sampleRate * duration;
|
||||
const noiseBuffer = ctx.createBuffer(1, bufferSize, sampleRate);
|
||||
const data = noiseBuffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = noiseBuffer;
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.3, 0);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, duration * 0.9);
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
source.start(0);
|
||||
}
|
||||
|
||||
const rendered = await ctx.startRendering();
|
||||
|
||||
// Convert to WAV blob
|
||||
const numChannels = 1;
|
||||
const length = rendered.length * numChannels * 2 + 44;
|
||||
const buffer = new ArrayBuffer(length);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// WAV header
|
||||
const writeString = (offset: number, str: string) => {
|
||||
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
|
||||
};
|
||||
writeString(0, 'RIFF');
|
||||
view.setUint32(4, length - 8, true);
|
||||
writeString(8, 'WAVE');
|
||||
writeString(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * numChannels * 2, true);
|
||||
view.setUint16(32, numChannels * 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(36, 'data');
|
||||
view.setUint32(40, rendered.length * numChannels * 2, true);
|
||||
|
||||
const channelData = rendered.getChannelData(0);
|
||||
let offset = 44;
|
||||
for (let i = 0; i < rendered.length; i++) {
|
||||
const sample = Math.max(-1, Math.min(1, channelData[i]));
|
||||
view.setInt16(offset, sample * 0x7FFF, true);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
const blob = new Blob([buffer], { type: 'audio/wav' });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
interface SoundEffectsPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SoundEffectsPanel — Categorized SFX library with Web Audio generated effects.
|
||||
*/
|
||||
export const SoundEffectsPanel: React.FC<SoundEffectsPanelProps> = ({ onClose }) => {
|
||||
const {
|
||||
layers, setLayers,
|
||||
activeLayerId, setActiveLayerId,
|
||||
setTimelineElements,
|
||||
setSelectedElementId,
|
||||
playerRef,
|
||||
durationInFrames,
|
||||
} = useEditor();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const [insertingId, setInsertingId] = useState<string | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Record<string, boolean>>(
|
||||
Object.fromEntries(SFX_CATEGORIES.map(c => [c.name, true]))
|
||||
);
|
||||
|
||||
// Preview a sound effect
|
||||
const handlePreview = useCallback(async (effect: SoundEffect) => {
|
||||
const id = effect.name;
|
||||
setPreviewingId(id);
|
||||
try {
|
||||
const url = await generateSfxAsync(effect);
|
||||
const audio = new Audio(url);
|
||||
audio.volume = 0.5;
|
||||
audio.play();
|
||||
audio.onended = () => {
|
||||
setPreviewingId(null);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
} catch {
|
||||
setPreviewingId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Insert into timeline
|
||||
const handleInsert = useCallback(async (effect: SoundEffect) => {
|
||||
const id = effect.name;
|
||||
setInsertingId(id);
|
||||
try {
|
||||
const url = await generateSfxAsync(effect);
|
||||
|
||||
// Upload to server for persistence
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `sfx-${effect.name.toLowerCase().replace(/\s+/g, '-')}.wav`, { type: 'audio/wav' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const uploadRes = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||
|
||||
let persistentUrl = url;
|
||||
if (uploadRes.ok) {
|
||||
const uploadData = await uploadRes.json();
|
||||
persistentUrl = uploadData.url;
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Find or create audio layer
|
||||
let targetLayerId = activeLayerId;
|
||||
const activeLayer = layers.find(l => l.id === activeLayerId);
|
||||
if (!activeLayer || activeLayer.type !== 'audio') {
|
||||
let audioLayer = layers.find(l => l.type === 'audio');
|
||||
if (!audioLayer) {
|
||||
audioLayer = { id: 'layer-audio-' + Date.now(), name: 'Audio', type: 'audio' };
|
||||
setLayers(prev => [...prev, audioLayer!]);
|
||||
}
|
||||
targetLayerId = audioLayer.id;
|
||||
setActiveLayerId(targetLayerId);
|
||||
}
|
||||
|
||||
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
|
||||
const newElement: TimelineElement = {
|
||||
id: 'sfx-' + Date.now(),
|
||||
layerId: targetLayerId,
|
||||
type: 'audio',
|
||||
content: persistentUrl,
|
||||
startFrame: currentFrame,
|
||||
endFrame: Math.min(durationInFrames, currentFrame + Math.round(effect.durationSec * 30)),
|
||||
x: 0,
|
||||
y: 0,
|
||||
originalFileName: `SFX: ${effect.name}`,
|
||||
};
|
||||
|
||||
setTimelineElements(prev => [...prev, newElement]);
|
||||
setSelectedElementId(newElement.id);
|
||||
} catch (err) {
|
||||
console.error('SFX insert error:', err);
|
||||
} finally {
|
||||
setInsertingId(null);
|
||||
}
|
||||
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
|
||||
|
||||
// Filter
|
||||
const filteredCategories = SFX_CATEGORIES
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
effects: cat.effects.filter(e =>
|
||||
!search || e.name.toLowerCase().includes(search.toLowerCase()) || e.description.toLowerCase().includes(search.toLowerCase())
|
||||
),
|
||||
}))
|
||||
.filter(cat => cat.effects.length > 0);
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
|
||||
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Volume2 size={14} className="text-emerald-400" />
|
||||
Efectos de Sonido
|
||||
</h3>
|
||||
<button onClick={onClose} title="Cerrar" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-3 border-b border-neutral-800/50">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar efectos..."
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg pl-8 pr-3 py-2 text-xs text-white outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2">
|
||||
{filteredCategories.map((cat) => (
|
||||
<div key={cat.name}>
|
||||
<button
|
||||
onClick={() => setExpandedCategories(prev => ({ ...prev, [cat.name]: !prev[cat.name] }))}
|
||||
title={`Categoría ${cat.name}`}
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-neutral-800/50 transition-colors text-left"
|
||||
>
|
||||
<span className="text-sm">{cat.emoji}</span>
|
||||
<span className="text-[11px] font-semibold text-neutral-300 flex-1">{cat.name}</span>
|
||||
<span className="text-[9px] text-neutral-600">{cat.effects.length}</span>
|
||||
</button>
|
||||
|
||||
{expandedCategories[cat.name] && (
|
||||
<div className="ml-1 space-y-0.5 mt-0.5">
|
||||
{cat.effects.map((effect) => {
|
||||
const effectId = effect.name;
|
||||
const isPreviewing = previewingId === effectId;
|
||||
const isInserting = insertingId === effectId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={effectId}
|
||||
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg hover:bg-neutral-800/40 transition-colors group"
|
||||
>
|
||||
{/* Preview button */}
|
||||
<button
|
||||
onClick={() => handlePreview(effect)}
|
||||
disabled={isPreviewing}
|
||||
title={`Previsualizar ${effect.name}`}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isPreviewing
|
||||
? 'text-emerald-400 animate-pulse'
|
||||
: 'text-neutral-600 hover:text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
<Volume2 size={12} />
|
||||
</button>
|
||||
|
||||
{/* Name + Desc */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-medium text-neutral-300 truncate">{effect.name}</div>
|
||||
<div className="text-[8px] text-neutral-600 truncate">{effect.description} · {effect.durationSec}s</div>
|
||||
</div>
|
||||
|
||||
{/* Insert button */}
|
||||
<button
|
||||
onClick={() => handleInsert(effect)}
|
||||
disabled={isInserting}
|
||||
title={`Insertar ${effect.name}`}
|
||||
className="p-1 rounded text-neutral-600 hover:text-emerald-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
{isInserting ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredCategories.length === 0 && (
|
||||
<div className="text-center py-6 text-neutral-600 text-xs">
|
||||
<Volume2 size={24} className="mx-auto mb-2 opacity-30" />
|
||||
<p>No se encontraron efectos</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { X, Stamp, Image as ImageIcon, Type, AtSign, Globe, Instagram } from 'lucide-react';
|
||||
import { useEditor } from '../../context/EditorContext';
|
||||
import { FileDropZone } from '../ui/FileDropZone';
|
||||
import { TimelineElement } from '../../types';
|
||||
|
||||
interface StickersPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel for brand assets: branded text presets, social handles, stickers.
|
||||
* Text presets use the brand font, color, and name from designMD.
|
||||
*/
|
||||
export const StickersPanel: React.FC<StickersPanelProps> = ({ onClose }) => {
|
||||
const {
|
||||
designMD, brandContent,
|
||||
layers, setLayers,
|
||||
activeLayerId, setActiveLayerId,
|
||||
setTimelineElements,
|
||||
setSelectedElementId,
|
||||
playerRef,
|
||||
durationInFrames,
|
||||
} = useEditor();
|
||||
|
||||
const brandName = designMD.brandName || 'Mi Marca';
|
||||
const font = designMD.baseFont || 'system-ui';
|
||||
const color = designMD.textColor || '#ffffff';
|
||||
const social = designMD.socialHandles || {};
|
||||
|
||||
const brandContentThumbnails = (brandContent || [])
|
||||
.filter(p => p.thumbnail)
|
||||
.map(p => ({ src: p.thumbnail!, name: p.name, id: p.id }));
|
||||
|
||||
const legacyStickers = designMD.brandStickers || [];
|
||||
|
||||
// Add branded text element to a visual layer
|
||||
const addBrandText = useCallback((content: string, fontSize?: number, y?: number) => {
|
||||
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
|
||||
const newId = 'el-' + Date.now();
|
||||
|
||||
let targetLayerId = activeLayerId;
|
||||
const activeLayer = layers.find(l => l.id === activeLayerId);
|
||||
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
|
||||
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
|
||||
if (!visualLayer) {
|
||||
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
|
||||
setLayers(prev => [...prev, visualLayer!]);
|
||||
}
|
||||
targetLayerId = visualLayer.id;
|
||||
setActiveLayerId(targetLayerId);
|
||||
}
|
||||
|
||||
const newElement: TimelineElement = {
|
||||
id: newId,
|
||||
layerId: targetLayerId,
|
||||
type: 'text',
|
||||
content,
|
||||
startFrame: currentFrame,
|
||||
endFrame: Math.min(durationInFrames, currentFrame + 100),
|
||||
x: 50,
|
||||
y: y ?? 50,
|
||||
fontSize,
|
||||
fontFamily: font,
|
||||
color: color,
|
||||
useBranding: true,
|
||||
};
|
||||
|
||||
setTimelineElements(prev => [...prev, newElement]);
|
||||
setSelectedElementId(newId);
|
||||
}, [layers, activeLayerId, playerRef, durationInFrames, setTimelineElements, setSelectedElementId, setLayers, setActiveLayerId, font, color]);
|
||||
|
||||
// Build social text presets
|
||||
const socialPresets: { label: string; content: string; icon: React.ReactNode }[] = [];
|
||||
if (social.instagram) socialPresets.push({ label: 'Instagram', content: social.instagram, icon: <Instagram size={12} /> });
|
||||
if (social.tiktok) socialPresets.push({ label: 'TikTok', content: social.tiktok, icon: <AtSign size={12} /> });
|
||||
if (social.twitter) socialPresets.push({ label: 'Twitter/X', content: social.twitter, icon: <AtSign size={12} /> });
|
||||
if (social.youtube) socialPresets.push({ label: 'YouTube', content: social.youtube, icon: <AtSign size={12} /> });
|
||||
if (social.website) socialPresets.push({ label: 'Web', content: social.website, icon: <Globe size={12} /> });
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
|
||||
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Stamp size={14} className="text-amber-400" />
|
||||
Marca
|
||||
</h3>
|
||||
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-1 overflow-y-auto space-y-4">
|
||||
|
||||
{/* ═══ Textos de Marca ═══ */}
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Textos de Marca</span>
|
||||
<div className="space-y-1.5">
|
||||
{/* Brand name — large */}
|
||||
<button
|
||||
onClick={() => addBrandText(brandName, 64, 40)}
|
||||
title={`Añadir "${brandName}" como título`}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 bg-neutral-950/60 border border-amber-900/30 rounded-lg text-left hover:border-amber-500/40 hover:bg-amber-950/20 transition-all group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md bg-amber-600/15 border border-amber-500/30 flex items-center justify-center shrink-0">
|
||||
<Type size={14} className="text-amber-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-bold text-white block truncate" style={{ fontFamily: font }}>{brandName}</span>
|
||||
<span className="text-[9px] text-neutral-600">Título grande · 64px</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Brand name — subtitle */}
|
||||
<button
|
||||
onClick={() => addBrandText(brandName, 36, 50)}
|
||||
title={`Añadir "${brandName}" como subtítulo`}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-amber-500/30 hover:bg-amber-950/10 transition-all group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-md bg-neutral-800 flex items-center justify-center shrink-0">
|
||||
<Type size={12} className="text-neutral-400 group-hover:text-amber-400 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] font-medium text-neutral-300 block truncate" style={{ fontFamily: font }}>{brandName}</span>
|
||||
<span className="text-[9px] text-neutral-600">Subtítulo · 36px</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Brand name — small watermark */}
|
||||
<button
|
||||
onClick={() => addBrandText(brandName, 20, 90)}
|
||||
title={`Añadir "${brandName}" como marca de agua`}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-amber-500/30 hover:bg-amber-950/10 transition-all group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-md bg-neutral-800 flex items-center justify-center shrink-0">
|
||||
<Type size={10} className="text-neutral-500 group-hover:text-amber-400 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[10px] text-neutral-400 block truncate" style={{ fontFamily: font }}>{brandName}</span>
|
||||
<span className="text-[9px] text-neutral-600">Marca de agua · 20px</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Redes Sociales ═══ */}
|
||||
{socialPresets.length > 0 && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Redes Sociales</span>
|
||||
<div className="space-y-1.5">
|
||||
{socialPresets.map((sp) => (
|
||||
<button
|
||||
key={sp.label}
|
||||
onClick={() => addBrandText(sp.content, 28, 85)}
|
||||
title={`Añadir ${sp.label}: ${sp.content}`}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 bg-neutral-950/60 border border-neutral-800/60 rounded-lg text-left hover:border-violet-500/30 hover:bg-violet-950/10 transition-all group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-md bg-violet-600/15 border border-violet-500/30 flex items-center justify-center shrink-0 text-violet-400">
|
||||
{sp.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] font-medium text-neutral-300 block truncate">{sp.content}</span>
|
||||
<span className="text-[9px] text-neutral-600">{sp.label} · 28px</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Contenido Visual de Marca ═══ */}
|
||||
{brandContentThumbnails.length > 0 && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Contenido Visual</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{brandContentThumbnails.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-2"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', item.src);
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'sticker', src: item.src, brandContentId: item.id }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<img src={item.src} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-300 drop-shadow-md" alt={item.name} draggable={false} />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center pointer-events-none">
|
||||
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
|
||||
<span className="text-[8px] text-neutral-300 mt-0.5">{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy Stickers */}
|
||||
{legacyStickers.length > 0 && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest mb-2 block">Stickers</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{legacyStickers.map((src, i) => (
|
||||
<div
|
||||
key={`sticker-${i}`}
|
||||
className="aspect-square bg-neutral-800 rounded-lg overflow-hidden group relative cursor-grab active:cursor-grabbing flex items-center justify-center p-2"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', src);
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'sticker', src }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<img src={src} className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-300 drop-shadow-md" alt="Sticker" draggable={false} />
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
||||
<span className="text-[10px] text-white bg-black/50 px-2 py-1 rounded">Arrastrar</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload */}
|
||||
<FileDropZone
|
||||
accept="image/*"
|
||||
multiple
|
||||
onFiles={() => {}}
|
||||
label="Subir assets de marca"
|
||||
sublabel="PNG con transparencia"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Search, Film, Image as ImageIcon, Loader2, Download, ExternalLink } from 'lucide-react';
|
||||
import { searchStockPhotos, searchStockVideos, downloadStockToServer, StockPhoto, StockVideo } from '../../utils/stockMediaApi';
|
||||
import { useEditor } from '../../context/EditorContext';
|
||||
import { TimelineElement } from '../../types';
|
||||
|
||||
type MediaType = 'photos' | 'videos';
|
||||
|
||||
/**
|
||||
* StockMediaTab — Search and insert stock photos/videos from Pexels.
|
||||
*/
|
||||
export const StockMediaTab: React.FC = () => {
|
||||
const {
|
||||
layers, setLayers,
|
||||
activeLayerId, setActiveLayerId,
|
||||
setTimelineElements,
|
||||
setSelectedElementId,
|
||||
playerRef,
|
||||
durationInFrames,
|
||||
} = useEditor();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [mediaType, setMediaType] = useState<MediaType>('photos');
|
||||
const [photos, setPhotos] = useState<StockPhoto[]>([]);
|
||||
const [videos, setVideos] = useState<StockVideo[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState<number | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ─── Search ───
|
||||
const doSearch = useCallback(async (q: string, p: number, type: MediaType, append = false) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (type === 'photos') {
|
||||
const result = await searchStockPhotos(q || 'trending', p);
|
||||
setPhotos(prev => append ? [...prev, ...result.items] : result.items);
|
||||
setHasMore(result.hasMore);
|
||||
} else {
|
||||
const result = await searchStockVideos(q || 'trending', p);
|
||||
setVideos(prev => append ? [...prev, ...result.items] : result.items);
|
||||
setHasMore(result.hasMore);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced search on query change
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
doSearch(query, 1, mediaType);
|
||||
}, 500);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [query, mediaType, doSearch]);
|
||||
|
||||
// Load more (infinite scroll)
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting && hasMore && !isLoading) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
doSearch(query, nextPage, mediaType, true);
|
||||
}
|
||||
}, { threshold: 0.5 });
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isLoading, page, query, mediaType, doSearch]);
|
||||
|
||||
// ─── Insert into canvas ───
|
||||
const insertPhoto = useCallback(async (photo: StockPhoto) => {
|
||||
setIsDownloading(photo.id);
|
||||
try {
|
||||
// Download to server for persistence
|
||||
const persistentUrl = await downloadStockToServer(photo.mediumUrl, `pexels-${photo.id}.jpg`);
|
||||
insertElement(persistentUrl, 'image');
|
||||
} catch {
|
||||
// Fallback: use direct URL
|
||||
insertElement(photo.mediumUrl, 'image');
|
||||
} finally {
|
||||
setIsDownloading(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const insertVideo = useCallback(async (video: StockVideo) => {
|
||||
setIsDownloading(video.id);
|
||||
try {
|
||||
const persistentUrl = await downloadStockToServer(video.videoUrl, `pexels-${video.id}.mp4`);
|
||||
insertElement(persistentUrl, 'video');
|
||||
} catch {
|
||||
insertElement(video.videoUrl, 'video');
|
||||
} finally {
|
||||
setIsDownloading(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const insertElement = useCallback((src: string, type: 'image' | 'video') => {
|
||||
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
|
||||
const newId = 'el-' + Date.now();
|
||||
|
||||
let targetLayerId = activeLayerId;
|
||||
const activeLayer = layers.find(l => l.id === activeLayerId);
|
||||
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'audio') {
|
||||
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
|
||||
if (!visualLayer) {
|
||||
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Visual', type: 'visual' };
|
||||
setLayers(prev => [...prev, visualLayer!]);
|
||||
}
|
||||
targetLayerId = visualLayer.id;
|
||||
setActiveLayerId(targetLayerId);
|
||||
}
|
||||
|
||||
const newElement: TimelineElement = {
|
||||
id: newId,
|
||||
layerId: targetLayerId,
|
||||
type,
|
||||
content: src,
|
||||
startFrame: currentFrame,
|
||||
endFrame: Math.min(durationInFrames, currentFrame + (type === 'video' ? 150 : 100)),
|
||||
x: 25,
|
||||
y: 25,
|
||||
width: 50,
|
||||
};
|
||||
|
||||
setTimelineElements(prev => [...prev, newElement]);
|
||||
setSelectedElementId(newId);
|
||||
}, [activeLayerId, layers, playerRef, durationInFrames, setLayers, setActiveLayerId, setTimelineElements, setSelectedElementId]);
|
||||
|
||||
const items = mediaType === 'photos' ? photos : videos;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search + Type Toggle */}
|
||||
<div className="p-3 space-y-2 border-b border-neutral-800/50">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar en Pexels..."
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-lg pl-8 pr-3 py-2 text-xs text-white outline-none focus:border-violet-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setMediaType('photos')}
|
||||
title="Buscar fotos"
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[10px] font-medium transition-all border ${
|
||||
mediaType === 'photos'
|
||||
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
|
||||
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon size={12} /> Fotos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMediaType('videos')}
|
||||
title="Buscar videos"
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[10px] font-medium transition-all border ${
|
||||
mediaType === 'videos'
|
||||
? 'bg-violet-600/20 border-violet-500/50 text-violet-300'
|
||||
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<Film size={12} /> Videos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Grid */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
|
||||
{items.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-neutral-600 text-xs">
|
||||
<Search size={24} className="mb-2 opacity-50" />
|
||||
<span>Busca fotos o videos gratis</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{mediaType === 'photos'
|
||||
? photos.map((photo) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
onClick={() => insertPhoto(photo)}
|
||||
title={`${photo.alt || 'Foto'} — ${photo.photographer}`}
|
||||
className="relative group rounded-lg overflow-hidden aspect-square bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/40 transition-all"
|
||||
disabled={isDownloading === photo.id}
|
||||
>
|
||||
<img
|
||||
src={photo.thumbUrl}
|
||||
alt={photo.alt}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-end">
|
||||
<span className="text-[8px] text-white/80 px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity truncate">
|
||||
📷 {photo.photographer}
|
||||
</span>
|
||||
</div>
|
||||
{isDownloading === photo.id && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={20} className="text-violet-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
: videos.map((video) => (
|
||||
<button
|
||||
key={video.id}
|
||||
onClick={() => insertVideo(video)}
|
||||
title={`Video — ${video.photographer} (${video.duration}s)`}
|
||||
className="relative group rounded-lg overflow-hidden aspect-video bg-neutral-900 border border-neutral-800/50 hover:border-violet-500/40 transition-all"
|
||||
disabled={isDownloading === video.id}
|
||||
>
|
||||
<img
|
||||
src={video.thumbUrl}
|
||||
alt="Video thumbnail"
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<Film size={24} className="text-white/70 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<span className="absolute bottom-1 right-1 text-[8px] text-white/80 bg-black/60 rounded px-1 py-0.5">
|
||||
{video.duration}s
|
||||
</span>
|
||||
{isDownloading === video.id && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={20} className="text-violet-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 size={16} className="text-violet-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{hasMore && <div ref={sentinelRef} className="h-4" />}
|
||||
|
||||
{/* Pexels attribution */}
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center justify-center gap-1 py-3 text-[9px] text-neutral-600">
|
||||
<ExternalLink size={8} />
|
||||
Fotos proporcionadas por <a href="https://pexels.com" target="_blank" rel="noopener" className="text-neutral-500 underline">Pexels</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { X, Type, Plus, AlignLeft, AlignCenter, Heading1, Heading2, Subtitles } from 'lucide-react';
|
||||
import { useEditor } from '../../context/EditorContext';
|
||||
import { TimelineElement } from '../../types';
|
||||
|
||||
interface TextPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TEXT_PRESETS = [
|
||||
{ label: 'Título', icon: <Heading1 size={14} />, content: 'Título', fontSize: 72, y: 30 },
|
||||
{ label: 'Subtítulo', icon: <Heading2 size={14} />, content: 'Subtítulo', fontSize: 48, y: 50 },
|
||||
{ label: 'Cuerpo', icon: <AlignLeft size={14} />, content: 'Texto de cuerpo', fontSize: 32, y: 60 },
|
||||
{ label: 'Lower Third', icon: <Subtitles size={14} />, content: 'Lower Third', fontSize: 28, y: 85 },
|
||||
{ label: 'Centrado', icon: <AlignCenter size={14} />, content: 'Texto Centrado', fontSize: 56, y: 50 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Panel for adding text elements. Auto-routes to a visual layer.
|
||||
*/
|
||||
export const TextPanel: React.FC<TextPanelProps> = ({ onClose }) => {
|
||||
const {
|
||||
layers, setLayers,
|
||||
activeLayerId, setActiveLayerId,
|
||||
setTimelineElements,
|
||||
setSelectedElementId,
|
||||
playerRef,
|
||||
durationInFrames,
|
||||
} = useEditor();
|
||||
|
||||
const addText = useCallback((content: string, fontSize?: number, y?: number) => {
|
||||
const currentFrame = playerRef.current?.getCurrentFrame() || 0;
|
||||
const newId = 'el-' + Date.now();
|
||||
|
||||
// Find or create a visual layer
|
||||
let targetLayerId = activeLayerId;
|
||||
const activeLayer = layers.find(l => l.id === activeLayerId);
|
||||
if (!activeLayer || activeLayer.type === 'brand' || activeLayer.type === 'video' || activeLayer.type === 'audio') {
|
||||
let visualLayer = layers.find(l => l.type === 'visual' || l.type == null);
|
||||
if (!visualLayer) {
|
||||
visualLayer = { id: 'layer-' + Date.now(), name: 'Capa Gráfica 1', type: 'visual' };
|
||||
setLayers(prev => [...prev, visualLayer!]);
|
||||
}
|
||||
targetLayerId = visualLayer.id;
|
||||
setActiveLayerId(targetLayerId);
|
||||
}
|
||||
|
||||
const newElement: TimelineElement = {
|
||||
id: newId,
|
||||
layerId: targetLayerId,
|
||||
type: 'text',
|
||||
content,
|
||||
startFrame: currentFrame,
|
||||
endFrame: Math.min(durationInFrames, currentFrame + 100),
|
||||
x: 50,
|
||||
y: y ?? 50,
|
||||
fontSize,
|
||||
};
|
||||
|
||||
setTimelineElements(prev => [...prev, newElement]);
|
||||
setSelectedElementId(newId);
|
||||
}, [layers, activeLayerId, playerRef, durationInFrames, setTimelineElements, setSelectedElementId, setLayers, setActiveLayerId]);
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-neutral-900 border-r border-neutral-800/60 flex flex-col h-full z-10 shrink-0 shadow-lg animate-in slide-in-from-left-2 duration-200">
|
||||
<div className="p-3 border-b border-neutral-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Type size={14} className="text-violet-400" />
|
||||
Texto
|
||||
</h3>
|
||||
<button onClick={onClose} title="Cerrar Panel" className="text-neutral-400 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-1 overflow-y-auto space-y-4">
|
||||
{/* Quick add */}
|
||||
<button
|
||||
onClick={() => addText('Nuevo Texto')}
|
||||
title="Añadir texto rápido"
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 bg-violet-600/20 border border-violet-500/40 text-violet-300 hover:bg-violet-600/30 hover:border-violet-400/60 rounded-lg transition-all text-sm font-medium"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Añadir Texto
|
||||
</button>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-neutral-500 uppercase tracking-widest">Plantillas</span>
|
||||
<div className="grid gap-1.5">
|
||||
{TEXT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => addText(preset.content, preset.fontSize, preset.y)}
|
||||
title={`Añadir ${preset.label}`}
|
||||
className="flex items-center gap-2.5 px-3 py-2 bg-neutral-950/50 border border-neutral-800/60 rounded-lg text-neutral-400 hover:text-white hover:border-neutral-700 hover:bg-neutral-800/50 transition-all text-left group"
|
||||
>
|
||||
<span className="text-neutral-500 group-hover:text-violet-400 transition-colors">{preset.icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-medium leading-tight">{preset.label}</span>
|
||||
<span className="text-[9px] text-neutral-600">{preset.fontSize}px</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user