feat: Add batch video export support with Export Queue and modal

This commit is contained in:
2026-06-02 22:16:51 -05:00
parent 9503dbfabc
commit 58d9b095a3
6 changed files with 370 additions and 17 deletions
+192
View File
@@ -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>
);
};