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
+79 -12
View File
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { Film, Volume2, Music, X, Upload, Wand2 } from 'lucide-react';
import { Film, Volume2, Music, X, Upload, Wand2, Maximize2, Minimize2, Move, Pipette } from 'lucide-react';
import { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
@@ -62,6 +62,10 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type:
}}
onEdit={() => onEditAsset?.('introVideoUrl', designMD.introVideoUrl || '')}
showEdit={!!(designMD.introVideoUrl && onEditAsset)}
fit={designMD.introVideoFit}
onFitChange={(fit) => handleDesignChange('introVideoFit', fit)}
bgColor={designMD.introVideoBgColor}
onBgColorChange={(color) => handleDesignChange('introVideoBgColor', color ?? '')}
/>
{/* ═══ Outro Video ═══ */}
@@ -80,6 +84,10 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type:
}}
onEdit={() => onEditAsset?.('outroVideoUrl', designMD.outroVideoUrl || '')}
showEdit={!!(designMD.outroVideoUrl && onEditAsset)}
fit={designMD.outroVideoFit}
onFitChange={(fit) => handleDesignChange('outroVideoFit', fit)}
bgColor={designMD.outroVideoBgColor}
onBgColorChange={(color) => handleDesignChange('outroVideoBgColor', color ?? '')}
/>
{/* ═══ Brand Audio ═══ */}
@@ -184,8 +192,13 @@ const VideoUploadSimple: React.FC<{
onClear: () => void;
onEdit?: () => void;
showEdit?: boolean;
}> = ({ label, description, videoUrl, accentColor, onUrlChange, onClear, onEdit, showEdit }) => {
fit?: 'cover' | 'contain' | 'fill';
onFitChange?: (fit: 'cover' | 'contain' | 'fill') => void;
bgColor?: string | null;
onBgColorChange?: (color: string | null) => void;
}> = ({ label, description, videoUrl, accentColor, onUrlChange, onClear, onEdit, showEdit, fit = 'cover', onFitChange, bgColor, onBgColorChange }) => {
const hasVideo = !!videoUrl && videoUrl.trim().length > 0;
const colorInputRef = React.useRef<HTMLInputElement>(null);
return (
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
@@ -270,16 +283,70 @@ const VideoUploadSimple: React.FC<{
{/* Status badge */}
{hasVideo && (
<div
className="flex items-center gap-1.5 text-[10px] font-medium px-2.5 py-1 rounded-lg w-fit"
style={{
backgroundColor: `${accentColor}15`,
color: accentColor,
border: `1px solid ${accentColor}30`,
}}
>
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
Video cargado
<div className="flex flex-col gap-3 pt-1 border-t border-neutral-800/50">
<div className="flex items-center gap-1">
<span className="text-[10px] text-neutral-500 mr-2">Ajuste de video:</span>
{([
{ key: 'cover' as const, label: 'Cover', icon: <Maximize2 size={10} />, tip: 'Llenar pantalla' },
{ key: 'contain' as const, label: 'Contain', icon: <Minimize2 size={10} />, tip: 'Mostrar completo' },
{ key: 'fill' as const, label: 'Fill', icon: <Move size={10} />, tip: 'Estirar' },
]).map(opt => (
<button
key={opt.key}
onClick={() => onFitChange?.(opt.key)}
title={opt.tip}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-[9px] font-medium transition-all border ${
fit === opt.key
? `border-[${accentColor}]/50 bg-[${accentColor}]/15 text-[${accentColor}]`
: 'border-neutral-800 bg-neutral-900 text-neutral-400 hover:text-neutral-200'
}`}
style={fit === opt.key ? { borderColor: `${accentColor}50`, backgroundColor: `${accentColor}15`, color: accentColor } : {}}
>
{opt.icon} {opt.label}
</button>
))}
</div>
{fit === 'contain' && onBgColorChange && (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-neutral-500 mr-2">Color de fondo:</span>
<button
onClick={() => colorInputRef.current?.click()}
title={bgColor ? `Color: ${bgColor}` : 'Seleccionar color de fondo'}
className="w-6 h-6 rounded border border-neutral-700 hover:border-neutral-500 transition-colors overflow-hidden flex items-center justify-center shrink-0"
style={{
backgroundColor: bgColor || undefined,
...(!bgColor ? {
backgroundImage: 'linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%)',
backgroundSize: '8px 8px',
backgroundPosition: '0 0, 0 4px, 4px -4px, -4px 0px',
} : {}),
}}
>
{!bgColor && <Pipette size={10} className="text-neutral-400" />}
</button>
<input
ref={colorInputRef}
type="color"
value={bgColor || '#000000'}
onChange={(e) => onBgColorChange(e.target.value)}
className="sr-only"
tabIndex={-1}
/>
<button
onClick={() => onBgColorChange(null)}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all border ${
!bgColor
? `border-[${accentColor}]/50 bg-[${accentColor}]/15 text-[${accentColor}]`
: 'border-neutral-800 bg-neutral-900 text-neutral-400 hover:text-neutral-200'
}`}
style={!bgColor ? { borderColor: `${accentColor}50`, backgroundColor: `${accentColor}15`, color: accentColor } : {}}
>
<X size={10} /> Transparente
</button>
{bgColor && <span className="text-[9px] text-neutral-500 font-mono ml-1">{bgColor}</span>}
</div>
)}
</div>
)}
</div>
@@ -266,6 +266,7 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
height: `${el.h ?? 100}%`,
overflow: 'hidden',
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
backgroundColor: el.containBgColor || undefined,
}}>
{el.chromaKeyEnabled ? (
<ChromaKeyVideo
+94 -4
View File
@@ -9,6 +9,8 @@ import {
TemplateField, BrandSource,
} from '../../types';
import { ExportModal } from '../export/ExportModal';
import { BatchExportModal } from '../export/BatchExportModal';
import { useExportQueue } from '../../context/ExportQueueContext';
import { compileExpressToTimeline, getTemplateDuration } from '../../utils/expressCompiler';
import { TemplateFieldInput } from '../shared/TemplateFieldInput';
import { LivePreviewCanvas } from '../shared/LivePreviewCanvas';
@@ -94,8 +96,11 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
// Batch mode state
const [batchExportProgress, setBatchExportProgress] = useState<BatchExportProgress | null>(null);
const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0);
const [showBatchExportModal, setShowBatchExportModal] = useState(false);
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
const { startExport } = useExportQueue();
const playerRef = useRef<BradlyPlayerRef>(null);
// Probe actual video durations for dynamic timeline
@@ -234,10 +239,15 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
const handleProducePro = () => { if (validate()) onProducePro(fieldData); };
const handleProduce = () => { if (validate()) setShowExportModal(true); };
// ─── Batch export handler ───
// ─── Batch export handler (Image ZIP or Open Video Modal) ───
const handleBatchExport = useCallback(async () => {
if (!batch.validateAll()) return;
if (template.format === 'video') {
setShowBatchExportModal(true);
return;
}
setBatchExportProgress({ current: 0, total: batch.pieceCount, status: 'rendering' });
try {
@@ -254,6 +264,77 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
}
}, [batch, template, brand]);
// ─── Enqueue Video Batch Jobs ───
const handleVideoBatchExport = useCallback(async (config: { format: 'mp4' | 'webm'; width: number; height: number; fps: number }) => {
setShowBatchExportModal(false);
// Iterate over each piece, compile its timeline, and queue it
for (const piece of batch.pieces) {
// 1. Prepare field data for this piece
const fd: Record<string, string> = { ...piece.fieldData };
if (backgroundFieldId && piece.backgroundUrl) {
fd[backgroundFieldId] = piece.backgroundUrl;
}
// 2. Compile to timeline elements
const compiled = compileExpressToTimeline(template, fd, designMD, brand, videoDurations);
// 3. Apply fit overrides
compiled.elements = compiled.elements.map(el => {
const fieldId = el.sourceFieldId;
const fitOverride = fieldId ? mediaFits[fieldId] : undefined;
const bgOverride = fieldId ? containBgColors[fieldId] : undefined;
return {
...el,
transitionIn: undefined,
transitionOut: undefined,
...(fitOverride ? { objectFit: fitOverride } : {}),
...(bgOverride !== undefined ? { containBgColor: bgOverride } : {}),
};
});
// 4. Calculate actual frames based on duration
const totalDuration = getTemplateDuration(template, videoDurations, designMD);
// The app's internal timeline is always calculated at a base of 30 fps
const BASE_FPS = 30;
const durationSeconds = totalDuration;
const actualFrames = Math.round(durationSeconds * config.fps);
// Scale timeline elements to match selected FPS
const fpsScale = config.fps / BASE_FPS;
const scaledElements = fpsScale === 1 ? compiled.elements : compiled.elements.map(el => {
const scaledEl = {
...el,
startFrame: Math.round(el.startFrame * fpsScale),
endFrame: Math.round(el.endFrame * fpsScale),
};
if (scaledEl.transitionIn) {
scaledEl.transitionIn = {
...scaledEl.transitionIn,
duration: Math.round(scaledEl.transitionIn.duration * fpsScale)
};
}
return scaledEl;
});
// 5. Send to queue
await startExport({
format: config.format,
width: config.width,
height: config.height,
fps: config.fps,
durationInFrames: actualFrames,
designMD,
textOverlay: '',
timelineElements: scaledElements,
layers: compiled.layers,
brandVisibility: { logo: false, frame: false, background: true },
outputFormat: 'video',
});
}
}, [batch.pieces, template, backgroundFieldId, designMD, brand, videoDurations, mediaFits, containBgColors, startExport]);
return (
<div className="flex-1 flex overflow-hidden bg-neutral-950 relative">
{/* Subtle grid background */}
@@ -547,15 +628,15 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
<button
onClick={handleBatchExport}
disabled={batch.pieceCount === 0 || (batchExportProgress?.status === 'rendering')}
title={batch.pieceCount === 0 ? 'Sube fondos para comenzar' : `Generar y descargar ${batch.pieceCount} piezas como ZIP`}
title={batch.pieceCount === 0 ? 'Sube fondos para comenzar' : `Generar ${batch.pieceCount} piezas`}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-[10px] font-bold transition-all shadow-lg hover:scale-[1.01] active:scale-[0.99] ${
batch.pieceCount > 0 && batchExportProgress?.status !== 'rendering'
? 'bg-gradient-to-r from-violet-600 to-fuchsia-600 hover:from-violet-500 hover:to-fuchsia-500 text-white shadow-violet-900/30 hover:shadow-violet-900/50'
: 'bg-neutral-800 text-neutral-500 cursor-not-allowed shadow-none'
}`}
>
<Package size={12} />
Descargar ZIP ({batch.pieceCount})
{template.format === 'video' ? <Zap size={12} /> : <Package size={12} />}
{template.format === 'video' ? `Encolar Renders (${batch.pieceCount})` : `Descargar ZIP (${batch.pieceCount})`}
<ChevronRight size={10} />
</button>
</>
@@ -658,6 +739,15 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
outputFormat={template.format}
aspectRatio={template.aspectRatio}
/>
{/* ═══ Batch Export Modal (video batch only) ═══ */}
<BatchExportModal
isOpen={showBatchExportModal}
onClose={() => setShowBatchExportModal(false)}
aspectRatio={template.aspectRatio}
pieceCount={batch.pieceCount}
onExport={handleVideoBatchExport}
/>
</div>
);
};
+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>
);
};
+2
View File
@@ -57,8 +57,10 @@ export interface DesignMD {
// Video fit & position
introVideoFit?: 'cover' | 'contain' | 'fill';
introVideoBgColor?: string | null;
introVideoPosition?: string;
outroVideoFit?: 'cover' | 'contain' | 'fill';
outroVideoBgColor?: string | null;
outroVideoPosition?: string;
// Freeform video placement (% of canvas)
introVideoX?: number;
+2 -1
View File
@@ -190,9 +190,10 @@ export function compileExpressToTimeline(
// CompositionElement's isFullscreenBrand path reads w/h (not width/height)
w: segW,
h: segH,
objectFit: scene.segmentVideoFit || (isIntro
objectFit: (isIntro
? (designMD.introVideoFit || 'cover')
: (designMD.outroVideoFit || 'cover')) as 'cover' | 'contain' | 'fill',
containBgColor: isIntro ? designMD.introVideoBgColor : designMD.outroVideoBgColor,
layerId: 'layer-express-bg',
isBrandElement: true,
isLocked: true,