fix(rendering): synchronize FPS, implement render locks, respect brand segment duration, and fix local audio path resolving for ffmpeg
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user