Refactor: remove AGPL imgly dependency and migrate background removal to python backend

This commit is contained in:
2026-06-02 14:50:25 -05:00
parent 560a413c1e
commit f998e454fe
25 changed files with 601 additions and 97 deletions
+4 -1
View File
@@ -14,6 +14,7 @@ interface BrandArchitectureProps {
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
onContinue: () => void;
onEditAsset?: (type: keyof DesignMD, url: string) => void;
}
const TABS = [
@@ -25,7 +26,7 @@ const TABS = [
type TabId = typeof TABS[number]['id'];
export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, handleCompanyChange, designMD, handleDesignChange, onContinue }) => {
export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, handleCompanyChange, designMD, handleDesignChange, onContinue, onEditAsset }) => {
const [zoom, setZoom] = useState(1);
const [aspectRatio, setAspectRatio] = useState<'16:9'|'1:1'|'9:16'>('9:16');
const [activeTab, setActiveTab] = useState<TabId>('general');
@@ -186,6 +187,7 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
<BrandTabVisual
designMD={designMD}
handleDesignChange={handleDesignChange}
onEditAsset={onEditAsset}
/>
)}
{activeTab === 'typography' && (
@@ -195,6 +197,7 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
<BrandTabMedia
designMD={designMD}
handleDesignChange={handleDesignChange}
onEditAsset={onEditAsset}
/>
)}
+3 -3
View File
@@ -85,9 +85,9 @@ export const StudioProperties: React.FC<StudioPropertiesProps> = ({
const isImageMode = outputFormat === 'image';
return (
<aside className="w-72 bg-neutral-900 border-l border-neutral-800/60 flex flex-col z-10 shrink-0" onClick={(e) => e.stopPropagation()}>
<aside className="w-72 bg-neutral-900 border-l border-neutral-800/60 flex flex-col z-10 shrink-0 h-full" onClick={(e) => e.stopPropagation()}>
{/* Properties section */}
<div className={isImageMode ? 'shrink-0 border-b border-neutral-800 overflow-y-auto max-h-[50%]' : 'flex-1 overflow-y-auto'}>
<div className={isImageMode ? 'flex-1 min-h-0 flex flex-col border-b border-neutral-800' : 'flex-1 min-h-0 flex flex-col'}>
{activeTool === 'transitions' ? (
<TransitionsPanel designMD={designMD} />
) : (selectedElementIds && selectedElementIds.size >= 2) ? (
@@ -137,7 +137,7 @@ export const StudioProperties: React.FC<StudioPropertiesProps> = ({
{/* Layers panel — image mode only (replaces the hidden timeline) */}
{isImageMode && (
<div className="flex-1 min-h-0 border-t border-neutral-800">
<div className="flex-1 min-h-0 flex flex-col bg-neutral-900">
<ImageLayersPanel
timelineElements={timelineElements}
setTimelineElements={setTimelineElements}
+13 -3
View File
@@ -18,8 +18,8 @@ interface StudioWorkspaceProps {
durationInFrames: number;
timelineElements?: TimelineElement[];
setTimelineElements?: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
aspectRatio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3';
setAspectRatio: (ratio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3') => void;
aspectRatio: string;
setAspectRatio: (ratio: string) => void;
outputFormat?: 'video' | 'image';
activeLayerId?: string;
/** Lifted zoom state for TopHeader integration */
@@ -262,7 +262,17 @@ export const StudioWorkspace: React.FC<StudioWorkspaceProps> = ({
if (aspectRatio === '1:1') return { width: 1080, height: 1080 };
if (aspectRatio === '4:5') return { width: 1080, height: 1350 };
if (aspectRatio === '4:3') return { width: 1440, height: 1080 };
return { width: 1080, height: 1920 }; // 9:16
if (aspectRatio === '9:16') return { width: 1080, height: 1920 };
// Custom aspect ratio fallback 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 { width: w, height: h };
}
return { width: 1080, height: 1920 }; // Default
};
const dimensions = getDimensions();
+17 -8
View File
@@ -15,8 +15,10 @@ interface TopHeaderProps {
onZoomOut?: () => void;
onZoomReset?: () => void;
/** Aspect ratio controls */
aspectRatio?: '16:9' | '1:1' | '9:16' | '4:5' | '4:3';
onAspectRatioChange?: (ratio: '16:9' | '1:1' | '9:16' | '4:5' | '4:3') => void;
aspectRatio?: string;
onAspectRatioChange?: (ratio: string) => void;
disableAspectControls?: boolean;
titleOverride?: React.ReactNode;
}
export const TopHeader: React.FC<TopHeaderProps> = ({
@@ -31,14 +33,19 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
onZoomReset,
aspectRatio = '9:16',
onAspectRatioChange,
disableAspectControls,
titleOverride,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
const isStudio = currentStep === 'studio';
return (
<header className="flex-none border-b border-neutral-800/60 bg-neutral-900/95 backdrop-blur-sm px-3 h-11 flex items-center justify-between z-30 relative">
<header
className="flex-none border-b border-neutral-800/60 bg-neutral-900/95 backdrop-blur-sm px-3 h-11 flex items-center justify-between z-30 relative"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
{/* Left: Hamburger + Logo */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 ml-[72px]" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
@@ -88,13 +95,15 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
<div className="bg-violet-600/20 p-1 rounded text-violet-400">
<LayoutTemplate size={14} />
</div>
<span className="text-xs font-semibold text-white tracking-tight">SaaS Branding</span>
<span className="text-xs font-semibold text-white tracking-tight">
{titleOverride || 'SaaS Branding'}
</span>
</div>
</div>
{/* Center: Zoom controls (only in studio) */}
{isStudio && onZoomIn && onZoomOut && (
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-1">
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-1" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<button
onClick={(e) => { e.stopPropagation(); onZoomOut(); }}
title="Zoom Out"
@@ -118,7 +127,7 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
</button>
{/* Aspect ratio pills */}
{onAspectRatioChange && (
{onAspectRatioChange && !disableAspectControls && (
<>
<div className="w-px h-4 bg-neutral-700 mx-1" />
{(['16:9', '9:16', '1:1', '4:5', '4:3'] as const).map(ratio => (
@@ -141,7 +150,7 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
)}
{/* Right: Editor buttons + Format badge */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 pr-2" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{/* Express / Pro buttons — only on dashboard */}
{currentStep === 'dashboard' && onStartExpressBlank && (
<button
+18 -3
View File
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { Film, Volume2, Music, X, Upload } from 'lucide-react';
import { Film, Volume2, Music, X, Upload, Wand2 } from 'lucide-react';
import { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
@@ -15,7 +15,7 @@ interface BrandTabMediaProps {
* All positioning, fit, duration, and blend controls live in the TemplateBuilder
* (per-template segment configuration), avoiding collisions.
*/
export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDesignChange }) => {
export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type: keyof DesignMD, url: string) => void }> = ({ designMD, handleDesignChange, onEditAsset }) => {
/** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */
const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => {
@@ -60,6 +60,8 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDe
handleDesignChange('introVideoUrl', '');
handleDesignChange('introDurationFrames', 60);
}}
onEdit={() => onEditAsset?.('introVideoUrl', designMD.introVideoUrl || '')}
showEdit={!!(designMD.introVideoUrl && onEditAsset)}
/>
{/* ═══ Outro Video ═══ */}
@@ -76,6 +78,8 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDe
handleDesignChange('outroVideoUrl', '');
handleDesignChange('outroDurationFrames', 60);
}}
onEdit={() => onEditAsset?.('outroVideoUrl', designMD.outroVideoUrl || '')}
showEdit={!!(designMD.outroVideoUrl && onEditAsset)}
/>
{/* ═══ Brand Audio ═══ */}
@@ -178,7 +182,9 @@ const VideoUploadSimple: React.FC<{
accentColor: string;
onUrlChange: (url: string) => void;
onClear: () => void;
}> = ({ label, description, videoUrl, accentColor, onUrlChange, onClear }) => {
onEdit?: () => void;
showEdit?: boolean;
}> = ({ label, description, videoUrl, accentColor, onUrlChange, onClear, onEdit, showEdit }) => {
const hasVideo = !!videoUrl && videoUrl.trim().length > 0;
return (
@@ -250,6 +256,15 @@ const VideoUploadSimple: React.FC<{
}
}}
/>
{showEdit && (
<button
onClick={onEdit}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg bg-violet-600/20 text-violet-400 hover:bg-violet-600/30 transition-colors text-xs font-semibold border border-violet-500/20"
>
<Wand2 size={14} />
Abrir en Editor Avanzado
</button>
)}
</div>
</div>
+24 -9
View File
@@ -1,27 +1,33 @@
import React, { useCallback } from 'react';
import { Settings2, ImageIcon } from 'lucide-react';
import { Settings2, ImageIcon, Wand2 } from 'lucide-react';
import { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
interface BrandTabVisualProps {
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
onEditAsset?: (type: keyof DesignMD, url: string) => void;
}
export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
designMD,
handleDesignChange,
onEditAsset,
}) => {
const handleLogoFiles = useCallback((files: File[]) => {
const handleLogoFiles = useCallback(async (files: File[]) => {
const file = files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
handleDesignChange('logoUrl', event.target.result as string);
}
};
reader.readAsDataURL(file);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
handleDesignChange('logoUrl', data.url);
} catch (err) {
console.error('Logo upload failed:', err);
}
}, [handleDesignChange]);
return (
@@ -53,6 +59,15 @@ export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
label="Subir desde archivo"
onFiles={handleLogoFiles}
/>
{designMD.logoUrl && onEditAsset && (
<button
onClick={() => onEditAsset('logoUrl', designMD.logoUrl)}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg bg-violet-600/20 text-violet-400 hover:bg-violet-600/30 transition-colors text-xs font-semibold border border-violet-500/20"
>
<Wand2 size={14} />
Abrir en Editor Avanzado
</button>
)}
</div>
</div>
</div>
+40 -6
View File
@@ -14,8 +14,8 @@ interface ExportModalProps {
durationInFrames: number;
brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
outputFormat?: 'video' | 'image';
/** Template aspect ratio — used to filter resolution presets */
aspectRatio?: '9:16' | '16:9' | '1:1' | '4:5' | '4:3';
aspectRatio?: string;
onAssetSaved?: (url: string) => void;
}
const FORMAT_OPTIONS: { value: RenderFormat; label: string; icon: typeof Film; desc: string }[] = [
@@ -53,6 +53,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
brandVisibility,
outputFormat,
aspectRatio,
onAssetSaved,
}) => {
const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue();
@@ -68,7 +69,24 @@ export const ExportModal: React.FC<ExportModalProps> = ({
const filteredPresets = useMemo(() => {
if (!aspectRatio) return RESOLUTION_PRESETS;
const matching = RESOLUTION_PRESETS.filter(p => p.ratio === aspectRatio);
return matching.length > 0 ? matching : RESOLUTION_PRESETS;
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);
@@ -94,6 +112,22 @@ export const ExportModal: React.FC<ExportModalProps> = ({
return FORMAT_OPTIONS;
}, [outputFormat]);
// Ensure selected format is valid for the current mode
React.useEffect(() => {
if (!filteredFormats.find(f => f.value === format)) {
setFormat(filteredFormats[0].value);
}
}, [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 {
@@ -128,8 +162,8 @@ export const ExportModal: React.FC<ExportModalProps> = ({
<Download size={18} className="text-violet-400" />
</div>
<div>
<h2 className="text-sm font-bold text-white">Exportar</h2>
<p className="text-[10px] text-neutral-500">Renderizar y descargar</p>
<h2 className="text-sm font-bold text-white">{onAssetSaved ? 'Guardar Activo de Marca' : 'Exportar'}</h2>
<p className="text-[10px] text-neutral-500">{onAssetSaved ? 'Renderizar y aplicar cambios' : 'Renderizar y descargar'}</p>
</div>
</div>
<div className="flex items-center gap-2">
@@ -284,7 +318,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
}`}
>
<Zap size={16} />
{isExporting ? 'Iniciando...' : `Exportar ${isStill ? 'Imagen' : 'Video'}`}
{isExporting ? 'Iniciando...' : (onAssetSaved ? 'Renderizar y Guardar' : `Exportar ${isStill ? 'Imagen' : 'Video'}`)}
<span className="text-[9px] opacity-60 font-mono">({estimatedSize})</span>
</button>
@@ -13,7 +13,7 @@ import { FilterPresets } from '../ui/FilterPresets';
import { TextStylePresets } from '../ui/TextStylePresets';
import { CollapsibleSection } from '../ui/CollapsibleSection';
import { useColorHistory } from '../../hooks/useColorHistory';
import { removeImageBackground } from '../../utils/backgroundRemoval';
interface ElementPropertiesPanelProps {
selectedElementId: string;
setSelectedElementId: (id: string | null) => void;
@@ -247,6 +247,7 @@ export const ElementPropertiesPanel: React.FC<ElementPropertiesPanelProps> = ({
const [showBorderEffects, setShowBorderEffects] = useState(false);
const [showShadow, setShowShadow] = useState(false);
const [showChromaKey, setShowChromaKey] = useState(false);
const [isRemovingBg, setIsRemovingBg] = useState(false);
const [copiedStyle, setCopiedStyle] = useState<Partial<TimelineElement> | null>(null);
const { recentColors, addColor } = useColorHistory();
@@ -582,6 +583,32 @@ export const ElementPropertiesPanel: React.FC<ElementPropertiesPanelProps> = ({
>
<Pipette size={10} /> Chroma
</button>
{/* IA Quitar Fondo */}
{(el.type === 'image' || el.type === 'sticker') && (
<button
disabled={isRemovingBg}
onClick={async () => {
try {
setIsRemovingBg(true);
const newUrl = await removeImageBackground(el.content);
update({ content: newUrl, chromaKeyEnabled: false, blendMode: 'normal' });
} catch (err) {
alert('Error al remover el fondo. Inténtalo de nuevo.');
} finally {
setIsRemovingBg(false);
}
}}
title="Magia IA: Remover fondo de la imagen automáticamente"
className={`flex-1 min-w-[60px] py-1.5 rounded-lg text-[9px] font-medium transition-all border flex items-center justify-center gap-1 ${
isRemovingBg
? 'bg-neutral-800 text-neutral-500 border-neutral-700 cursor-not-allowed'
: 'bg-violet-600/20 border-violet-500/60 text-violet-300 hover:bg-violet-600/30'
}`}
>
{isRemovingBg ? <Loader2 size={10} className="animate-spin" /> : <Wand2 size={10} />}
{isRemovingBg ? 'Procesando...' : 'IA Fondo'}
</button>
)}
</div>
{/* ── Chroma Key Controls ── */}
@@ -4,6 +4,7 @@ import { CollapsibleSection } from '../ui/CollapsibleSection';
import type { BradlyPlayerRef } from '../../engine/player';
import { EXPORT_PRESETS } from '../../config/constants';
import { TimelineElement, TimelineLayer } from '../../types';
import { useEditor } from '../../context/EditorContext';
import { ProjectStats } from '../ui/ProjectStats';
import { QuickElementTemplates } from '../ui/QuickElementTemplates';
import { BulkActionsBar } from '../ui/BulkActionsBar';
@@ -32,6 +33,7 @@ export const GlobalSettingsPanel: React.FC<GlobalSettingsPanelProps> = ({
timelineElements, setTimelineElements, showGrid, setShowGrid, showSafeZone, setShowSafeZone,
onShowRenderHistory, layers, durationInFrames, fps,
}) => {
const { editingBrandAsset } = useEditor();
// ═══ Export frame as PNG ═══
const handleExportFrame = useCallback(() => {
const player = playerRef?.current;
@@ -277,11 +279,11 @@ export const GlobalSettingsPanel: React.FC<GlobalSettingsPanelProps> = ({
</button>
{/* Render button */}
<button
title="Exportar Video"
title={editingBrandAsset ? "Guardar Activo de Marca" : "Exportar Video"}
onClick={onExportClick}
className="w-full bg-violet-600 hover:bg-violet-500 text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2 text-sm shadow-xl shadow-violet-900/20"
>
<Play size={16} fill="currentColor" /> Renderizar
<Play size={16} fill="currentColor" /> {editingBrandAsset ? "Guardar Activo de Marca" : "Renderizar"}
</button>
{/* Project Save/Load */}
<div className="flex gap-1.5 mt-1">
+9 -4
View File
@@ -29,7 +29,7 @@ import { RenderProps, TimelineElement } from '../../types';
* StudioEditor: The main editing view.
* Reads all state from EditorContext — no prop drilling needed.
*/
export const StudioEditor: React.FC = () => {
export const StudioEditor: React.FC<{ onAssetSaved?: (url: string) => void }> = ({ onAssetSaved }) => {
const {
timelineElements, setTimelineElements,
layers, setLayers,
@@ -50,6 +50,7 @@ export const StudioEditor: React.FC = () => {
brandVisibility, setBrandVisibility,
activeAction, setActiveAction,
selectedElementIds, toggleElementSelection, clearSelection,
editingBrandAsset,
} = useEditor();
// Panel state (replaces old activeTool for toolbar)
@@ -70,6 +71,7 @@ export const StudioEditor: React.FC = () => {
// Auto-save after 2s of inactivity
useEffect(() => {
if (editingBrandAsset) return; // Disable autosave in brand asset mode
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(() => {
try {
@@ -88,6 +90,7 @@ export const StudioEditor: React.FC = () => {
// Auto-load on mount (only if no elements exist)
useEffect(() => {
if (editingBrandAsset) return; // Disable autoload in brand asset mode
try {
const saved = localStorage.getItem(AUTOSAVE_KEY);
if (!saved) return;
@@ -236,9 +239,9 @@ export const StudioEditor: React.FC = () => {
onElementDelete: handleDelete,
onElementLock: handleLock,
activeAction,
brandVisibility,
brandVisibility: editingBrandAsset ? { logo: false, frame: false, background: false } : brandVisibility,
outputFormat,
}), [designMD, textOverlay, layers, timelineElements, selectedElementId, activeLayerId, activeAction, brandVisibility, outputFormat, handleElementClick, handlePositionChange, handleTransformChange, handleDuplicate, handleDelete, handleLock]);
}), [designMD, textOverlay, layers, timelineElements, selectedElementId, activeLayerId, activeAction, brandVisibility, outputFormat, handleElementClick, handlePositionChange, handleTransformChange, handleDuplicate, handleDelete, handleLock, editingBrandAsset]);
return (
<>
@@ -408,8 +411,10 @@ export const StudioEditor: React.FC = () => {
timelineElements={timelineElements}
layers={layers}
durationInFrames={durationInFrames}
brandVisibility={brandVisibility}
brandVisibility={editingBrandAsset ? { logo: false, frame: false, background: false } : brandVisibility}
outputFormat={outputFormat}
aspectRatio={aspectRatio}
onAssetSaved={onAssetSaved}
/>
{/* Shortcuts Overlay */}
+11 -1
View File
@@ -11,7 +11,15 @@ interface StudioTopBarProps {
* Lives inside EditorProvider so it can access canvas zoom state.
*/
export const StudioTopBar: React.FC<StudioTopBarProps> = ({ setCurrentStep }) => {
const { canvasZoom, setCanvasZoom, aspectRatio, setAspectRatio, outputFormat } = useEditor();
const { canvasZoom, setCanvasZoom, aspectRatio, setAspectRatio, outputFormat, editingBrandAsset } = useEditor();
const titleOverride = editingBrandAsset ? (
<span>Editando Activo: <span className="text-violet-400">{
editingBrandAsset.type === 'logoUrl' ? 'Logo' :
editingBrandAsset.type === 'introVideoUrl' ? 'Video Intro' :
'Video Outro'
}</span></span>
) : undefined;
return (
<TopHeader
@@ -24,6 +32,8 @@ export const StudioTopBar: React.FC<StudioTopBarProps> = ({ setCurrentStep }) =>
onZoomReset={() => setCanvasZoom(1)}
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
disableAspectControls={!!editingBrandAsset}
titleOverride={titleOverride}
/>
);
};