feat: Add batch video export support with Export Queue and modal
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
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 { DesignMD } from '../../types';
|
||||||
import { FileDropZone } from '../ui/FileDropZone';
|
import { FileDropZone } from '../ui/FileDropZone';
|
||||||
|
|
||||||
@@ -62,6 +62,10 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type:
|
|||||||
}}
|
}}
|
||||||
onEdit={() => onEditAsset?.('introVideoUrl', designMD.introVideoUrl || '')}
|
onEdit={() => onEditAsset?.('introVideoUrl', designMD.introVideoUrl || '')}
|
||||||
showEdit={!!(designMD.introVideoUrl && onEditAsset)}
|
showEdit={!!(designMD.introVideoUrl && onEditAsset)}
|
||||||
|
fit={designMD.introVideoFit}
|
||||||
|
onFitChange={(fit) => handleDesignChange('introVideoFit', fit)}
|
||||||
|
bgColor={designMD.introVideoBgColor}
|
||||||
|
onBgColorChange={(color) => handleDesignChange('introVideoBgColor', color ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ═══ Outro Video ═══ */}
|
{/* ═══ Outro Video ═══ */}
|
||||||
@@ -80,6 +84,10 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type:
|
|||||||
}}
|
}}
|
||||||
onEdit={() => onEditAsset?.('outroVideoUrl', designMD.outroVideoUrl || '')}
|
onEdit={() => onEditAsset?.('outroVideoUrl', designMD.outroVideoUrl || '')}
|
||||||
showEdit={!!(designMD.outroVideoUrl && onEditAsset)}
|
showEdit={!!(designMD.outroVideoUrl && onEditAsset)}
|
||||||
|
fit={designMD.outroVideoFit}
|
||||||
|
onFitChange={(fit) => handleDesignChange('outroVideoFit', fit)}
|
||||||
|
bgColor={designMD.outroVideoBgColor}
|
||||||
|
onBgColorChange={(color) => handleDesignChange('outroVideoBgColor', color ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ═══ Brand Audio ═══ */}
|
{/* ═══ Brand Audio ═══ */}
|
||||||
@@ -184,8 +192,13 @@ const VideoUploadSimple: React.FC<{
|
|||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
showEdit?: boolean;
|
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 hasVideo = !!videoUrl && videoUrl.trim().length > 0;
|
||||||
|
const colorInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
|
<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 */}
|
{/* Status badge */}
|
||||||
{hasVideo && (
|
{hasVideo && (
|
||||||
<div
|
<div className="flex flex-col gap-3 pt-1 border-t border-neutral-800/50">
|
||||||
className="flex items-center gap-1.5 text-[10px] font-medium px-2.5 py-1 rounded-lg w-fit"
|
<div className="flex items-center gap-1">
|
||||||
style={{
|
<span className="text-[10px] text-neutral-500 mr-2">Ajuste de video:</span>
|
||||||
backgroundColor: `${accentColor}15`,
|
{([
|
||||||
color: accentColor,
|
{ key: 'cover' as const, label: 'Cover', icon: <Maximize2 size={10} />, tip: 'Llenar pantalla' },
|
||||||
border: `1px solid ${accentColor}30`,
|
{ 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 => (
|
||||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: accentColor }} />
|
<button
|
||||||
Video cargado
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ export const CompositionElement: React.FC<CompositionElementProps> = ({
|
|||||||
height: `${el.h ?? 100}%`,
|
height: `${el.h ?? 100}%`,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
|
borderRadius: (el.w ?? 100) < 100 || (el.h ?? 100) < 100 ? 8 : 0,
|
||||||
|
backgroundColor: el.containBgColor || undefined,
|
||||||
}}>
|
}}>
|
||||||
{el.chromaKeyEnabled ? (
|
{el.chromaKeyEnabled ? (
|
||||||
<ChromaKeyVideo
|
<ChromaKeyVideo
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
TemplateField, BrandSource,
|
TemplateField, BrandSource,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { ExportModal } from '../export/ExportModal';
|
import { ExportModal } from '../export/ExportModal';
|
||||||
|
import { BatchExportModal } from '../export/BatchExportModal';
|
||||||
|
import { useExportQueue } from '../../context/ExportQueueContext';
|
||||||
import { compileExpressToTimeline, getTemplateDuration } from '../../utils/expressCompiler';
|
import { compileExpressToTimeline, getTemplateDuration } from '../../utils/expressCompiler';
|
||||||
import { TemplateFieldInput } from '../shared/TemplateFieldInput';
|
import { TemplateFieldInput } from '../shared/TemplateFieldInput';
|
||||||
import { LivePreviewCanvas } from '../shared/LivePreviewCanvas';
|
import { LivePreviewCanvas } from '../shared/LivePreviewCanvas';
|
||||||
@@ -94,8 +96,11 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
// Batch mode state
|
// Batch mode state
|
||||||
const [batchExportProgress, setBatchExportProgress] = useState<BatchExportProgress | null>(null);
|
const [batchExportProgress, setBatchExportProgress] = useState<BatchExportProgress | null>(null);
|
||||||
const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0);
|
const [activeBatchPieceIndex, setActiveBatchPieceIndex] = useState(0);
|
||||||
|
const [showBatchExportModal, setShowBatchExportModal] = useState(false);
|
||||||
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
|
const backgroundFieldId = useMemo(() => findBackgroundFieldId(template), [template]);
|
||||||
|
|
||||||
|
const { startExport } = useExportQueue();
|
||||||
|
|
||||||
const playerRef = useRef<BradlyPlayerRef>(null);
|
const playerRef = useRef<BradlyPlayerRef>(null);
|
||||||
|
|
||||||
// Probe actual video durations for dynamic timeline
|
// Probe actual video durations for dynamic timeline
|
||||||
@@ -234,10 +239,15 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
const handleProducePro = () => { if (validate()) onProducePro(fieldData); };
|
const handleProducePro = () => { if (validate()) onProducePro(fieldData); };
|
||||||
const handleProduce = () => { if (validate()) setShowExportModal(true); };
|
const handleProduce = () => { if (validate()) setShowExportModal(true); };
|
||||||
|
|
||||||
// ─── Batch export handler ───
|
// ─── Batch export handler (Image ZIP or Open Video Modal) ───
|
||||||
const handleBatchExport = useCallback(async () => {
|
const handleBatchExport = useCallback(async () => {
|
||||||
if (!batch.validateAll()) return;
|
if (!batch.validateAll()) return;
|
||||||
|
|
||||||
|
if (template.format === 'video') {
|
||||||
|
setShowBatchExportModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setBatchExportProgress({ current: 0, total: batch.pieceCount, status: 'rendering' });
|
setBatchExportProgress({ current: 0, total: batch.pieceCount, status: 'rendering' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -254,6 +264,77 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [batch, template, brand]);
|
}, [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 (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden bg-neutral-950 relative">
|
<div className="flex-1 flex overflow-hidden bg-neutral-950 relative">
|
||||||
{/* Subtle grid background */}
|
{/* Subtle grid background */}
|
||||||
@@ -547,15 +628,15 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={handleBatchExport}
|
onClick={handleBatchExport}
|
||||||
disabled={batch.pieceCount === 0 || (batchExportProgress?.status === 'rendering')}
|
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] ${
|
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'
|
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-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'
|
: 'bg-neutral-800 text-neutral-500 cursor-not-allowed shadow-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Package size={12} />
|
{template.format === 'video' ? <Zap size={12} /> : <Package size={12} />}
|
||||||
Descargar ZIP ({batch.pieceCount})
|
{template.format === 'video' ? `Encolar Renders (${batch.pieceCount})` : `Descargar ZIP (${batch.pieceCount})`}
|
||||||
<ChevronRight size={10} />
|
<ChevronRight size={10} />
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -658,6 +739,15 @@ export const ProductionForm: React.FC<ProductionFormProps> = ({
|
|||||||
outputFormat={template.format}
|
outputFormat={template.format}
|
||||||
aspectRatio={template.aspectRatio}
|
aspectRatio={template.aspectRatio}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ═══ Batch Export Modal (video batch only) ═══ */}
|
||||||
|
<BatchExportModal
|
||||||
|
isOpen={showBatchExportModal}
|
||||||
|
onClose={() => setShowBatchExportModal(false)}
|
||||||
|
aspectRatio={template.aspectRatio}
|
||||||
|
pieceCount={batch.pieceCount}
|
||||||
|
onExport={handleVideoBatchExport}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,8 +57,10 @@ export interface DesignMD {
|
|||||||
|
|
||||||
// Video fit & position
|
// Video fit & position
|
||||||
introVideoFit?: 'cover' | 'contain' | 'fill';
|
introVideoFit?: 'cover' | 'contain' | 'fill';
|
||||||
|
introVideoBgColor?: string | null;
|
||||||
introVideoPosition?: string;
|
introVideoPosition?: string;
|
||||||
outroVideoFit?: 'cover' | 'contain' | 'fill';
|
outroVideoFit?: 'cover' | 'contain' | 'fill';
|
||||||
|
outroVideoBgColor?: string | null;
|
||||||
outroVideoPosition?: string;
|
outroVideoPosition?: string;
|
||||||
// Freeform video placement (% of canvas)
|
// Freeform video placement (% of canvas)
|
||||||
introVideoX?: number;
|
introVideoX?: number;
|
||||||
|
|||||||
@@ -190,9 +190,10 @@ export function compileExpressToTimeline(
|
|||||||
// CompositionElement's isFullscreenBrand path reads w/h (not width/height)
|
// CompositionElement's isFullscreenBrand path reads w/h (not width/height)
|
||||||
w: segW,
|
w: segW,
|
||||||
h: segH,
|
h: segH,
|
||||||
objectFit: scene.segmentVideoFit || (isIntro
|
objectFit: (isIntro
|
||||||
? (designMD.introVideoFit || 'cover')
|
? (designMD.introVideoFit || 'cover')
|
||||||
: (designMD.outroVideoFit || 'cover')) as 'cover' | 'contain' | 'fill',
|
: (designMD.outroVideoFit || 'cover')) as 'cover' | 'contain' | 'fill',
|
||||||
|
containBgColor: isIntro ? designMD.introVideoBgColor : designMD.outroVideoBgColor,
|
||||||
layerId: 'layer-express-bg',
|
layerId: 'layer-express-bg',
|
||||||
isBrandElement: true,
|
isBrandElement: true,
|
||||||
isLocked: true,
|
isLocked: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user