feat: Add batch video export support with Export Queue and modal
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { X, Film, Zap, Package, ChevronDown } from 'lucide-react';
|
||||
import type { RenderFormat } from '../../context/ExportQueueContext';
|
||||
|
||||
interface BatchExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
aspectRatio?: string;
|
||||
pieceCount: number;
|
||||
onExport: (config: { format: RenderFormat; width: number; height: number; fps: number }) => void;
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS: { value: RenderFormat; label: string; icon: typeof Film; desc: string }[] = [
|
||||
{ value: 'mp4', label: 'MP4', icon: Film, desc: 'Compatible con todo' },
|
||||
{ value: 'webm', label: 'WebM', icon: Film, desc: 'Web optimizado' },
|
||||
];
|
||||
|
||||
const RESOLUTION_PRESETS = [
|
||||
{ label: '1080×1080', w: 1080, h: 1080, desc: 'Instagram Post', ratio: '1:1' },
|
||||
{ label: '720×720', w: 720, h: 720, desc: 'Preview rápido', ratio: '1:1' },
|
||||
{ label: '1080×1920', w: 1080, h: 1920, desc: 'Story / Reel', ratio: '9:16' },
|
||||
{ label: '720×1280', w: 720, h: 1280, desc: 'Preview rápido', ratio: '9:16' },
|
||||
{ label: '1920×1080', w: 1920, h: 1080, desc: 'YouTube / TV', ratio: '16:9' },
|
||||
{ label: '1280×720', w: 1280, h: 720, desc: 'HD 720p', ratio: '16:9' },
|
||||
{ label: '1080×1350', w: 1080, h: 1350, desc: 'Feed 4:5', ratio: '4:5' },
|
||||
{ label: '720×900', w: 720, h: 900, desc: 'Preview 4:5', ratio: '4:5' },
|
||||
{ label: '1440×1080', w: 1440, h: 1080, desc: 'Pantalla 4:3', ratio: '4:3' },
|
||||
{ label: '960×720', w: 960, h: 720, desc: 'Preview 4:3', ratio: '4:3' },
|
||||
];
|
||||
|
||||
export const BatchExportModal: React.FC<BatchExportModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
aspectRatio,
|
||||
pieceCount,
|
||||
onExport,
|
||||
}) => {
|
||||
const [format, setFormat] = useState<RenderFormat>('mp4');
|
||||
const [fps, setFps] = useState(30);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Filter resolution presets to match the template's aspect ratio
|
||||
const filteredPresets = useMemo(() => {
|
||||
if (!aspectRatio) return RESOLUTION_PRESETS;
|
||||
const matching = RESOLUTION_PRESETS.filter(p => p.ratio === aspectRatio);
|
||||
if (matching.length > 0) return matching;
|
||||
|
||||
// Parse custom W:H
|
||||
const parts = aspectRatio.split(':');
|
||||
if (parts.length === 2) {
|
||||
const w = parseInt(parts[0], 10);
|
||||
const h = parseInt(parts[1], 10);
|
||||
if (!isNaN(w) && !isNaN(h)) {
|
||||
return [{
|
||||
label: `${w}x${h}`,
|
||||
w: w,
|
||||
h: h,
|
||||
desc: 'Resolución Original',
|
||||
ratio: aspectRatio
|
||||
}];
|
||||
}
|
||||
}
|
||||
return RESOLUTION_PRESETS;
|
||||
}, [aspectRatio]);
|
||||
|
||||
const [resIdx, setResIdx] = useState(0);
|
||||
const selectedRes = filteredPresets[resIdx] || filteredPresets[0];
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-neutral-950 border border-neutral-800 rounded-2xl shadow-2xl shadow-black/80 w-[420px] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-gradient-to-br from-violet-500/20 to-fuchsia-500/20 border border-violet-500/20">
|
||||
<Package size={18} className="text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-white">Exportación en Lote</h2>
|
||||
<p className="text-[10px] text-neutral-500">{pieceCount} videos en cola</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} title="Cerrar" className="p-1.5 rounded-lg hover:bg-neutral-800 transition-colors text-neutral-400 hover:text-white">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Formato de Video</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{FORMAT_OPTIONS.map(opt => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setFormat(opt.value)}
|
||||
title={opt.desc}
|
||||
className={`p-3 rounded-xl border transition-all flex items-center gap-2.5 ${
|
||||
format === opt.value
|
||||
? 'bg-violet-600/15 border-violet-500/50 text-violet-300 shadow-lg shadow-violet-500/5'
|
||||
: 'bg-neutral-900/50 border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">{opt.label}</div>
|
||||
<div className="text-[9px] text-neutral-500">{opt.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-neutral-500 uppercase tracking-wider mb-2">Resolución General</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredPresets.map((preset, idx) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => setResIdx(idx)}
|
||||
title={preset.desc}
|
||||
className={`px-3 py-1.5 rounded-lg border text-[10px] font-medium transition-all ${
|
||||
resIdx === idx
|
||||
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
|
||||
: 'bg-neutral-900/50 border-neutral-800 text-neutral-500 hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[9px] text-neutral-600 mt-1">{selectedRes.desc}</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-1 text-[10px] text-neutral-500 hover:text-neutral-300 transition-colors"
|
||||
>
|
||||
<ChevronDown size={10} className={`transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
|
||||
Opciones de Rendimiento
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 bg-neutral-900/50 border border-neutral-800/50 rounded-lg p-3 space-y-3">
|
||||
<div>
|
||||
<label className="block text-[10px] text-neutral-500 mb-1">FPS</label>
|
||||
<div className="flex gap-1.5">
|
||||
{[24, 30, 60].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFps(f)}
|
||||
className={`px-3 py-1 rounded-md text-[10px] font-medium border transition-all ${
|
||||
fps === f
|
||||
? 'bg-violet-600/15 border-violet-500/50 text-violet-300'
|
||||
: 'bg-neutral-900 border-neutral-800 text-neutral-500 hover:border-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{f} fps
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-neutral-800/50 bg-neutral-950 flex items-center justify-between">
|
||||
<p className="text-[10px] text-neutral-500">
|
||||
Se agregarán a la cola de procesamiento.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onExport({ format, width: selectedRes.w, height: selectedRes.h, fps })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white text-xs font-bold transition-all shadow-lg shadow-violet-900/30"
|
||||
>
|
||||
<Zap size={14} />
|
||||
Encolar {pieceCount} Videos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user