fix(rendering): synchronize FPS, implement render locks, respect brand segment duration, and fix local audio path resolving for ffmpeg

This commit is contained in:
2026-06-02 20:40:30 -05:00
parent b7656cf8eb
commit 9503dbfabc
39 changed files with 3556 additions and 3506 deletions
+46 -17
View File
@@ -1,6 +1,7 @@
import React, { useState, useMemo } from 'react';
import { X, Download, Film, Image as ImageIcon, Wifi, WifiOff, Zap, ChevronDown } from 'lucide-react';
import { useExportQueue, RenderFormat, ExportConfig } from '../../hooks/useExportQueue';
import { useExportQueue, RenderFormat, ExportConfig } from '../../context/ExportQueueContext';
import { useToast } from '../ui/ToastProvider';
import { ExportJobItem } from './ExportJobItem';
import type { DesignMD, TimelineElement, TimelineLayer } from '../../types';
@@ -57,6 +58,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
}) => {
const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue();
const { showToast } = useToast();
const [format, setFormat] = useState<RenderFormat>('mp4');
const [fps, setFps] = useState(30);
const [isExporting, setIsExporting] = useState(false);
@@ -92,17 +94,43 @@ export const ExportModal: React.FC<ExportModalProps> = ({
const [resIdx, setResIdx] = useState(0);
const selectedRes = filteredPresets[resIdx] || filteredPresets[0];
// The app's internal timeline is always calculated at a base of 30 fps
const BASE_FPS = 30;
const durationSeconds = durationInFrames / BASE_FPS;
const actualFrames = isStill ? 1 : Math.round(durationSeconds * fps);
// Scale timeline elements to match selected FPS
const scaledTimelineElements = useMemo(() => {
const fpsScale = fps / BASE_FPS;
if (fpsScale === 1) return timelineElements;
return timelineElements.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;
});
}, [timelineElements, fps]);
// Estimated file size
const estimatedSize = useMemo(() => {
if (isStill) return '~0.5 MB';
const seconds = durationInFrames / fps;
const pixels = selectedRes.w * selectedRes.h;
const bitrateMap: Record<string, number> = { mp4: 5, webm: 3, gif: 15 };
const bitrate = bitrateMap[format] ?? 5; // MB per minute per megapixel
const mp = pixels / 1_000_000;
const sizeMB = (seconds / 60) * bitrate * mp;
const sizeMB = (durationSeconds / 60) * bitrate * mp;
return sizeMB < 1 ? `~${Math.round(sizeMB * 1024)} KB` : `~${sizeMB.toFixed(1)} MB`;
}, [format, selectedRes, durationInFrames, fps, isStill]);
}, [format, selectedRes, durationSeconds, fps, isStill]);
// Auto-select image format in image mode
const filteredFormats = useMemo(() => {
@@ -119,15 +147,6 @@ export const ExportModal: React.FC<ExportModalProps> = ({
}
}, [filteredFormats, format]);
// Track finished jobs to call onAssetSaved automatically
React.useEffect(() => {
if (!onAssetSaved) return;
const completedJob = jobs.find(j => j.status === 'completed' && j.resultUrl);
if (completedJob && completedJob.resultUrl) {
onAssetSaved(completedJob.resultUrl);
}
}, [jobs, onAssetSaved]);
const handleExport = async () => {
setIsExporting(true);
try {
@@ -136,15 +155,25 @@ export const ExportModal: React.FC<ExportModalProps> = ({
width: selectedRes.w,
height: selectedRes.h,
fps,
durationInFrames: isStill ? 1 : durationInFrames,
durationInFrames: actualFrames,
designMD,
textOverlay,
timelineElements,
timelineElements: scaledTimelineElements,
layers,
brandVisibility,
outputFormat,
};
await startExport(config);
const job = await startExport(config, {
onSuccess: onAssetSaved ? (url) => {
onAssetSaved(url);
} : undefined
});
if (job) {
showToast('Renderizado en segundo plano iniciado', 'info');
onClose(); // Close modal immediately so user can keep interacting
}
} finally {
setIsExporting(false);
}
@@ -266,7 +295,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
</div>
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-500">Duración</span>
<span className="text-neutral-400 font-mono">{(durationInFrames / fps).toFixed(1)}s ({durationInFrames} frames)</span>
<span className="text-neutral-400 font-mono">{durationSeconds.toFixed(1)}s ({actualFrames} frames)</span>
</div>
{/* Quality Tier */}
<div>
@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { useExportQueue } from '../../context/ExportQueueContext';
import { Loader2, Download, Film, Image as ImageIcon, X, Zap } from 'lucide-react';
export const GlobalExportWidget: React.FC = () => {
const { jobs, activeJobs, hasActiveJobs, downloadJob, cancelJob } = useExportQueue();
const [isOpen, setIsOpen] = useState(false);
if (jobs.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end gap-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{/* Popover list of jobs */}
{isOpen && (
<div className="w-80 bg-neutral-900 border border-neutral-700/50 rounded-xl shadow-2xl shadow-black/80 overflow-hidden flex flex-col animate-in slide-in-from-bottom-2 fade-in">
<div className="flex items-center justify-between p-3 border-b border-neutral-800 bg-neutral-900">
<h3 className="text-xs font-semibold text-white flex items-center gap-2">
<Zap size={14} className="text-violet-400" />
Cola de Renderizado
</h3>
<button
onClick={() => setIsOpen(false)}
className="text-neutral-500 hover:text-white p-1 rounded-md hover:bg-neutral-800 transition-colors"
>
<X size={14} />
</button>
</div>
<div className="max-h-[300px] overflow-y-auto custom-scrollbar bg-neutral-950 p-2 space-y-2">
{jobs.map(job => {
const isDone = job.status === 'done';
const isError = job.status === 'error';
const isWorking = job.status === 'rendering' || job.status === 'queued';
const Icon = job.format === 'png' || job.format === 'jpeg' ? ImageIcon : Film;
return (
<div key={job.id} className="bg-neutral-900 border border-neutral-800 rounded-lg p-3 text-xs flex flex-col gap-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Icon size={14} className={isDone ? 'text-green-400' : isError ? 'text-red-400' : 'text-violet-400'} />
<span className="font-medium text-neutral-200">
{job.format.toUpperCase()} ({job.width}x{job.height})
</span>
</div>
{isWorking && (
<button
onClick={() => cancelJob(job.id)}
className="text-neutral-500 hover:text-red-400 p-1"
title="Cancelar render"
>
<X size={12} />
</button>
)}
</div>
{isWorking && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-[10px]">
<span className="text-neutral-400">
{job.status === 'queued' ? 'En cola...' : 'Renderizando...'}
</span>
<span className="font-mono text-violet-400">{job.progress}%</span>
</div>
<div className="h-1.5 w-full bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-violet-500 transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
)}
{isDone && (
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-green-400 bg-green-500/10 px-2 py-0.5 rounded font-medium">Completado</span>
<button
onClick={() => downloadJob(job)}
className="flex items-center gap-1 text-[10px] bg-violet-600 hover:bg-violet-500 text-white px-2 py-1 rounded transition-colors"
>
<Download size={12} /> Descargar
</button>
</div>
)}
{isError && (
<div className="mt-1">
<span className="text-[10px] text-red-400 bg-red-500/10 px-2 py-0.5 rounded font-medium">
{job.error || 'Error al renderizar'}
</span>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Floating Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-full shadow-lg shadow-black/50 transition-all border ${
hasActiveJobs
? 'bg-neutral-900 border-violet-500/50 text-white'
: 'bg-neutral-900 border-neutral-700/50 text-neutral-400 hover:text-white hover:border-neutral-600'
}`}
>
{hasActiveJobs ? (
<>
<Loader2 size={16} className="animate-spin text-violet-400" />
<span className="text-xs font-semibold">
{activeJobs.length} renderizando...
</span>
<span className="text-[10px] font-mono text-violet-300 bg-violet-500/20 px-1.5 py-0.5 rounded ml-1">
{activeJobs[0]?.progress || 0}%
</span>
</>
) : (
<>
<Zap size={16} />
<span className="text-xs font-medium">Cola de Render ({jobs.length})</span>
</>
)}
</button>
</div>
);
};