From 58d9b095a3d8c789240088d405ede7796b0e7f2b Mon Sep 17 00:00:00 2001 From: "kevinguevaradevia@gmail.com" Date: Tue, 2 Jun 2026 22:16:51 -0500 Subject: [PATCH] feat: Add batch video export support with Export Queue and modal --- src/components/brand/BrandTabMedia.tsx | 91 +++++++-- .../composition/CompositionElement.tsx | 1 + src/components/dashboard/ProductionForm.tsx | 98 ++++++++- src/components/export/BatchExportModal.tsx | 192 ++++++++++++++++++ src/types.ts | 2 + src/utils/expressCompiler.ts | 3 +- 6 files changed, 370 insertions(+), 17 deletions(-) create mode 100644 src/components/export/BatchExportModal.tsx diff --git a/src/components/brand/BrandTabMedia.tsx b/src/components/brand/BrandTabMedia.tsx index 4c3e9cc..229c7ca 100644 --- a/src/components/brand/BrandTabMedia.tsx +++ b/src/components/brand/BrandTabMedia.tsx @@ -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 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 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(null); return (
@@ -270,16 +283,70 @@ const VideoUploadSimple: React.FC<{ {/* Status badge */} {hasVideo && ( -
-
- Video cargado +
+
+ Ajuste de video: + {([ + { key: 'cover' as const, label: 'Cover', icon: , tip: 'Llenar pantalla' }, + { key: 'contain' as const, label: 'Contain', icon: , tip: 'Mostrar completo' }, + { key: 'fill' as const, label: 'Fill', icon: , tip: 'Estirar' }, + ]).map(opt => ( + + ))} +
+ + {fit === 'contain' && onBgColorChange && ( +
+ Color de fondo: + + onBgColorChange(e.target.value)} + className="sr-only" + tabIndex={-1} + /> + + {bgColor && {bgColor}} +
+ )}
)}
diff --git a/src/components/composition/CompositionElement.tsx b/src/components/composition/CompositionElement.tsx index 5f216bc..10661d2 100644 --- a/src/components/composition/CompositionElement.tsx +++ b/src/components/composition/CompositionElement.tsx @@ -266,6 +266,7 @@ export const CompositionElement: React.FC = ({ height: `${el.h ?? 100}%`, overflow: 'hidden', borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0, + backgroundColor: el.containBgColor || undefined, }}> {el.chromaKeyEnabled ? ( = ({ // Batch mode state const [batchExportProgress, setBatchExportProgress] = useState(null); const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0); + const [showBatchExportModal, setShowBatchExportModal] = useState(false); const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]); + const { startExport } = useExportQueue(); + const playerRef = useRef(null); // Probe actual video durations for dynamic timeline @@ -234,10 +239,15 @@ export const ProductionForm: React.FC = ({ 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 = ({ } }, [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 = { ...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 (
{/* Subtle grid background */} @@ -547,15 +628,15 @@ export const ProductionForm: React.FC = ({ @@ -658,6 +739,15 @@ export const ProductionForm: React.FC = ({ outputFormat={template.format} aspectRatio={template.aspectRatio} /> + + {/* ═══ Batch Export Modal (video batch only) ═══ */} + setShowBatchExportModal(false)} + aspectRatio={template.aspectRatio} + pieceCount={batch.pieceCount} + onExport={handleVideoBatchExport} + />
); }; diff --git a/src/components/export/BatchExportModal.tsx b/src/components/export/BatchExportModal.tsx new file mode 100644 index 0000000..32724d2 --- /dev/null +++ b/src/components/export/BatchExportModal.tsx @@ -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 = ({ + isOpen, + onClose, + aspectRatio, + pieceCount, + onExport, +}) => { + const [format, setFormat] = useState('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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Exportación en Lote

+

{pieceCount} videos en cola

+
+
+ +
+ + {/* Body */} +
+ {/* Format Selection */} +
+ +
+ {FORMAT_OPTIONS.map(opt => { + const Icon = opt.icon; + return ( + + ); + })} +
+
+ + {/* Resolution */} +
+ +
+ {filteredPresets.map((preset, idx) => ( + + ))} +
+

{selectedRes.desc}

+
+ + {/* Advanced Settings */} +
+ + {showAdvanced && ( +
+
+ +
+ {[24, 30, 60].map(f => ( + + ))} +
+
+
+ )} +
+
+ + {/* Footer */} +
+

+ Se agregarán a la cola de procesamiento. +

+ +
+
+
+ ); +}; diff --git a/src/types.ts b/src/types.ts index 3921b80..7e39cee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/src/utils/expressCompiler.ts b/src/utils/expressCompiler.ts index 9adc2a3..b2243ee 100644 --- a/src/utils/expressCompiler.ts +++ b/src/utils/expressCompiler.ts @@ -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,