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
+13 -13
View File
@@ -41,7 +41,7 @@
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.22.4",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.3" "vite": "^6.2.3"
} }
@@ -4436,15 +4436,6 @@
"devtools-protocol": "*" "devtools-protocol": "*"
} }
}, },
"node_modules/chromium-bidi/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/clean-stack": { "node_modules/clean-stack": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
@@ -10576,9 +10567,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.22.3", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -11762,6 +11753,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }
+2 -2
View File
@@ -22,7 +22,6 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@google/genai": "^2.4.0", "@google/genai": "^2.4.0",
"puppeteer-core": "^24.9.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
@@ -34,6 +33,7 @@
"motion": "^12.23.24", "motion": "^12.23.24",
"multer": "^2.1.1", "multer": "^2.1.1",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"puppeteer-core": "^24.9.0",
"react": "^19.0.1", "react": "^19.0.1",
"react-dom": "^19.0.1", "react-dom": "^19.0.1",
"vite": "^6.2.3" "vite": "^6.2.3"
@@ -52,7 +52,7 @@
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.22.4",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.3" "vite": "^6.2.3"
} }
+35
View File
@@ -0,0 +1,35 @@
FROM python:3.10-slim
WORKDIR /app
# Install system dependencies
# We need ffmpeg for video processing and libsm6 libxext6 for opencv (if needed by backgroundremover)
RUN apt-get update && apt-get install -y \
ffmpeg \
libsm6 \
libxext6 \
wget \
&& rm -rf /var/lib/apt/lists/*
# Pre-create the directory for u2net models so they persist if mounted,
# or at least get downloaded to a known location
ENV U2NET_HOME=/root/.u2net
RUN mkdir -p /root/.u2net
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# We explicitly pre-download the model by running backgroundremover on a dummy image if we wanted,
# but the script downloads it on first run. To avoid huge first-request times,
# you could add a script here to download the u2net model directly.
# wget https://github.com/nadermx/backgroundremover/raw/main/models/u2net.pth -O /root/.u2net/u2net.pth (if available)
COPY . .
EXPOSE 8000
# Using shm-size is handled in docker-compose, but we set workers to 1
# because video processing is extremely heavy and multiprocessing can crash without enough shm
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
@@ -0,0 +1,27 @@
version: '3.8'
services:
background-remover:
build: .
container_name: background-remover-service
ports:
- "8000:8000"
volumes:
# Mount the models folder so it doesn't download the model every time the container is recreated
- u2net_models:/root/.u2net
# Mount for local development if needed
- .:/app
# Video processing uses multiprocessing and requires shared memory
shm_size: '2g'
restart: unless-stopped
# If a GPU becomes available, you would uncomment the following lines (and ensure the Dockerfile uses a CUDA base image)
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
volumes:
u2net_models:
+192
View File
@@ -0,0 +1,192 @@
import os
import subprocess
import tempfile
import shutil
import asyncio
from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Background Remover API")
# Configure CORS so the React app can call this API
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Adjust in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def cleanup_files(*file_paths):
"""Deletes temporary files after the response is sent."""
for file_path in file_paths:
try:
if file_path and os.path.exists(file_path):
os.remove(file_path)
print(f"Cleaned up {file_path}")
except Exception as e:
print(f"Error cleaning up {file_path}: {e}")
@app.get("/health")
def health_check():
return {"status": "ok"}
@app.post("/api/v1/remove-background")
async def remove_background(
background_tasks: BackgroundTasks,
file: UploadFile = File(...)
):
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
# We will use secure temp files
temp_input_fd, temp_input_path = tempfile.mkstemp(suffix=".mp4")
temp_output_mov_fd, temp_output_mov_path = tempfile.mkstemp(suffix=".mov")
temp_final_webm_fd, temp_final_webm_path = tempfile.mkstemp(suffix=".webm")
os.close(temp_input_fd)
os.close(temp_output_mov_fd)
os.close(temp_final_webm_fd)
try:
# 1. Save uploaded video to temp file
with open(temp_input_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
print(f"Saved uploaded file to {temp_input_path}")
# 2. Run backgroundremover
# backgroundremover -i input.mp4 -mk -o output.mov
cmd_bg_remover = [
"backgroundremover",
"-i", temp_input_path,
"-mk", # This flag tells it to create an alpha matte video (.mov)
"-o", temp_output_mov_path
]
print("Starting background removal process...")
process_bg = await asyncio.create_subprocess_exec(
*cmd_bg_remover,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process_bg.communicate()
if process_bg.returncode != 0:
print(f"backgroundremover error: {stderr.decode()}")
raise HTTPException(status_code=500, detail="Error processing video background removal.")
print("Background removal finished. Starting WebM conversion...")
# 3. Convert .mov to .webm with VP9 and yuva420p (alpha channel)
# ffmpeg -i output.mov -c:v libvpx-vp9 -pix_fmt yuva420p -auto-alt-ref 0 final.webm
cmd_ffmpeg = [
"ffmpeg",
"-y", # Overwrite output
"-i", temp_output_mov_path,
"-c:v", "libvpx-vp9",
"-pix_fmt", "yuva420p",
"-auto-alt-ref", "0",
temp_final_webm_path
]
process_ffmpeg = await asyncio.create_subprocess_exec(
*cmd_ffmpeg,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout_ff, stderr_ff = await process_ffmpeg.communicate()
if process_ffmpeg.returncode != 0:
print(f"ffmpeg error: {stderr_ff.decode()}")
raise HTTPException(status_code=500, detail="Error converting video to WebM.")
print("WebM conversion finished successfully.")
# 4. Schedule cleanup of ALL temporary files after response is sent
background_tasks.add_task(
cleanup_files,
temp_input_path,
temp_output_mov_path,
temp_final_webm_path
)
# 5. Return the file
return FileResponse(
temp_final_webm_path,
media_type="video/webm",
filename=f"transparent_{file.filename.split('.')[0]}.webm"
)
except Exception as e:
# If an error occurs, clean up immediately
cleanup_files(temp_input_path, temp_output_mov_path, temp_final_webm_path)
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/v1/remove-image-background")
async def remove_image_background(
background_tasks: BackgroundTasks,
file: UploadFile = File(...)
):
if not file.filename:
raise HTTPException(status_code=400, detail="No file uploaded")
# We will use secure temp files
temp_input_fd, temp_input_path = tempfile.mkstemp(suffix=".png")
temp_output_png_fd, temp_output_png_path = tempfile.mkstemp(suffix=".png")
os.close(temp_input_fd)
os.close(temp_output_png_fd)
try:
# 1. Save uploaded image to temp file
with open(temp_input_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
print(f"Saved uploaded image to {temp_input_path}")
# 2. Run backgroundremover for image
# backgroundremover -i input.jpg -o output.png
cmd_bg_remover = [
"backgroundremover",
"-i", temp_input_path,
"-o", temp_output_png_path
]
print("Starting image background removal process...")
process_bg = await asyncio.create_subprocess_exec(
*cmd_bg_remover,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process_bg.communicate()
if process_bg.returncode != 0:
print(f"backgroundremover error: {stderr.decode()}")
raise HTTPException(status_code=500, detail="Error processing image background removal.")
print("Image background removal finished successfully.")
# 3. Schedule cleanup of ALL temporary files after response is sent
background_tasks.add_task(
cleanup_files,
temp_input_path,
temp_output_png_path
)
# 4. Return the file
return FileResponse(
temp_output_png_path,
media_type="image/png",
filename=f"transparent_{file.filename.split('.')[0]}.png"
)
except Exception as e:
# If an error occurs, clean up immediately
cleanup_files(temp_input_path, temp_output_png_path)
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,4 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
python-multipart==0.0.9
backgroundremover==0.2.2
+59 -33
View File
@@ -7,7 +7,7 @@ import { ProductionForm } from './components/dashboard/ProductionForm';
import { StudioEditor } from './components/studio/StudioEditor'; import { StudioEditor } from './components/studio/StudioEditor';
import { ExpressEditor } from './components/express/ExpressEditor'; import { ExpressEditor } from './components/express/ExpressEditor';
import { StudioTopBar } from './components/studio/StudioTopBar'; import { StudioTopBar } from './components/studio/StudioTopBar';
import { EditorProvider, useEditor } from './context/EditorContext'; import { EditorProvider } from './context/EditorContext';
import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults'; import { DEFAULT_DESIGN_MD, PREDEFINED_COMPANIES, DEFAULT_PILLARS } from './data/defaults';
import { useCustomTooltips } from './hooks/useCustomTooltips'; import { useCustomTooltips } from './hooks/useCustomTooltips';
import { ToastProvider } from './components/ui/ToastProvider'; import { ToastProvider } from './components/ui/ToastProvider';
@@ -17,6 +17,7 @@ import { TemplateBuilder } from './components/express/builder/TemplateBuilder';
import { EXPRESS_TEMPLATES } from './config/expressTemplates'; import { EXPRESS_TEMPLATES } from './config/expressTemplates';
import { compileExpressToTimeline } from './utils/expressCompiler'; import { compileExpressToTimeline } from './utils/expressCompiler';
import { FullscreenToggle } from './components/ui/FullscreenToggle'; import { FullscreenToggle } from './components/ui/FullscreenToggle';
import { detectMediaDimensionsAndAspect } from './utils/mediaDimensions';
type Step = 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form'; type Step = 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form';
@@ -41,6 +42,7 @@ export default function App() {
const [currentStep, setCurrentStep] = useState<Step>('dashboard'); const [currentStep, setCurrentStep] = useState<Step>('dashboard');
const [designMD, setDesignMD] = useState<DesignMD>(DEFAULT_DESIGN_MD); const [designMD, setDesignMD] = useState<DesignMD>(DEFAULT_DESIGN_MD);
const [outputFormat, setOutputFormat] = useState<'video' | 'image'>('video'); const [outputFormat, setOutputFormat] = useState<'video' | 'image'>('video');
const [editingBrandAsset, setEditingBrandAsset] = useState<{ type: keyof DesignMD; url: string } | null>(null);
// Global templates (decoupled from brands) — persisted // Global templates (decoupled from brands) — persisted
const [globalTemplates, setGlobalTemplates] = useState<ExpressTemplate[]>(() => { const [globalTemplates, setGlobalTemplates] = useState<ExpressTemplate[]>(() => {
@@ -121,32 +123,6 @@ export default function App() {
}); });
}; };
const saveCurrentProject = (elements: TimelineElement[], layers: TimelineLayer[]) => {
if (currentCompanyId) {
setCompanies(prev => prev.map(c => {
if (c.id !== currentCompanyId) return c;
const projs = c.projects || [];
if (currentProjectId) {
return {
...c,
projects: projs.map(p => p.id === currentProjectId ? { ...p, elements, layers } : p)
};
} else {
const newId = `proj-${Date.now()}`;
const newProject: Project = {
id: newId,
name: `Proyecto ${outputFormat === 'video' ? 'Video' : 'Imagen'} ${projs.length + 1}`,
format: outputFormat,
elements,
layers
};
setCurrentProjectId(newId);
return { ...c, projects: [...projs, newProject] };
}
}));
}
};
const enterStudio = (design: DesignMD, format: 'video' | 'image', elements: TimelineElement[], layers: TimelineLayer[], companyId?: string, projectId?: string | null) => { const enterStudio = (design: DesignMD, format: 'video' | 'image', elements: TimelineElement[], layers: TimelineLayer[], companyId?: string, projectId?: string | null) => {
if (companyId) setCurrentCompanyId(companyId); if (companyId) setCurrentCompanyId(companyId);
if (projectId !== undefined) setCurrentProjectId(projectId); if (projectId !== undefined) setCurrentProjectId(projectId);
@@ -158,7 +134,57 @@ export default function App() {
setCurrentStep('studio'); setCurrentStep('studio');
}; };
// ── Blank canvas editors (no brand) ── const handleEditAsset = useCallback(async (assetInfo: { type: keyof DesignMD; url: string }) => {
try {
const isVideo = assetInfo.type === 'introVideoUrl' || assetInfo.type === 'outroVideoUrl';
const dimensions = await detectMediaDimensionsAndAspect(assetInfo.url, isVideo ? 'video' : 'image');
const layerId = 'layer-1';
const element: TimelineElement = dimensions.format === 'video' ? {
id: `brand-asset-${Date.now()}`,
layerId,
type: 'video',
content: assetInfo.url,
startFrame: 0,
endFrame: 150,
x: 50, y: 50,
width: 100, height: 100,
objectFit: 'cover',
opacity: 1,
isBrandElement: false,
isLocked: true,
} : {
id: `brand-asset-${Date.now()}`,
layerId,
type: 'image',
content: assetInfo.url,
startFrame: 0,
endFrame: 150,
x: 50, y: 50,
width: 100, height: 100,
objectFit: 'cover',
opacity: 1,
isBrandElement: false,
isLocked: true,
};
setEditingBrandAsset(assetInfo);
setTemplateBuilderAspect(dimensions.aspect as ExpressTemplate['aspectRatio']);
enterStudio(designMD, dimensions.format, [element], [{ id: layerId, name: 'Fondo del Activo', type: 'background' }], currentCompanyId || undefined, null);
} catch (err) {
console.error('Failed to detect dimensions:', err);
alert('Error al leer las dimensiones del archivo original.');
}
}, [designMD, currentCompanyId]);
const handleAssetSaved = useCallback((url: string) => {
if (editingBrandAsset) {
handleDesignChange(editingBrandAsset.type, url);
setEditingBrandAsset(null);
setCurrentStep('brand');
}
}, [editingBrandAsset]);
const handleStartExpressBlank = useCallback(() => { const handleStartExpressBlank = useCallback(() => {
setCurrentCompanyId(null); setCurrentCompanyId(null);
setDesignMD(DEFAULT_DESIGN_MD); setDesignMD(DEFAULT_DESIGN_MD);
@@ -183,14 +209,12 @@ export default function App() {
enterStudio(DEFAULT_DESIGN_MD, 'video', initialElements, initialLayers, undefined, null); enterStudio(DEFAULT_DESIGN_MD, 'video', initialElements, initialLayers, undefined, null);
}, []); }, []);
// ── Production flow: template × brand → form → editor ──
const handleGenerate = useCallback((template: ExpressTemplate, brand: CompanyProfile) => { const handleGenerate = useCallback((template: ExpressTemplate, brand: CompanyProfile) => {
setProductionTemplate(template); setProductionTemplate(template);
setProductionBrand(brand); setProductionBrand(brand);
setCurrentStep('production-form'); setCurrentStep('production-form');
}, []); }, []);
// ── Template management (edit / duplicate / delete) ──
const handleEditTemplate = useCallback((template: ExpressTemplate) => { const handleEditTemplate = useCallback((template: ExpressTemplate) => {
setEditingGlobalTemplate(template); setEditingGlobalTemplate(template);
setTemplateBuilderFormat(template.format); setTemplateBuilderFormat(template.format);
@@ -215,7 +239,6 @@ export default function App() {
const handleProducePro = useCallback((fieldData: Record<string, string>) => { const handleProducePro = useCallback((fieldData: Record<string, string>) => {
if (!productionTemplate || !productionBrand) return; if (!productionTemplate || !productionBrand) return;
// Compile template + brand + fieldData → TimelineElement[]
const compiled = compileExpressToTimeline(productionTemplate, fieldData, productionBrand.design, productionBrand); const compiled = compileExpressToTimeline(productionTemplate, fieldData, productionBrand.design, productionBrand);
enterStudio(productionBrand.design, productionTemplate.format, compiled.elements, compiled.layers, productionBrand.id, null); enterStudio(productionBrand.design, productionTemplate.format, compiled.elements, compiled.layers, productionBrand.id, null);
}, [productionTemplate, productionBrand]); }, [productionTemplate, productionBrand]);
@@ -313,6 +336,7 @@ export default function App() {
designMD={designMD} designMD={designMD}
handleDesignChange={handleDesignChange} handleDesignChange={handleDesignChange}
onContinue={() => setCurrentStep('dashboard')} onContinue={() => setCurrentStep('dashboard')}
onEditAsset={(type, url) => handleEditAsset({ type, url })}
/> />
)} )}
@@ -359,11 +383,13 @@ export default function App() {
initialElements={studioInitialElements} initialElements={studioInitialElements}
initialLayers={studioInitialLayers} initialLayers={studioInitialLayers}
initialFormat={outputFormat} initialFormat={outputFormat}
brandContent={companies.find(c => c.id === currentCompanyId)?.brandContent} initialAspect={templateBuilderAspect}
brandContent={currentCompanyId ? (getContentForCompany(currentCompanyId).pieces || []) : []}
editingBrandAsset={editingBrandAsset}
> >
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<StudioTopBar setCurrentStep={setCurrentStep} /> <StudioTopBar setCurrentStep={setCurrentStep} />
<StudioEditor /> <StudioEditor onAssetSaved={handleAssetSaved} />
</div> </div>
</EditorProvider> </EditorProvider>
)} )}
+4 -1
View File
@@ -14,6 +14,7 @@ interface BrandArchitectureProps {
designMD: DesignMD; designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void; handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
onContinue: () => void; onContinue: () => void;
onEditAsset?: (type: keyof DesignMD, url: string) => void;
} }
const TABS = [ const TABS = [
@@ -25,7 +26,7 @@ const TABS = [
type TabId = typeof TABS[number]['id']; 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 [zoom, setZoom] = useState(1);
const [aspectRatio, setAspectRatio] = useState<'16:9'|'1:1'|'9:16'>('9:16'); const [aspectRatio, setAspectRatio] = useState<'16:9'|'1:1'|'9:16'>('9:16');
const [activeTab, setActiveTab] = useState<TabId>('general'); const [activeTab, setActiveTab] = useState<TabId>('general');
@@ -186,6 +187,7 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
<BrandTabVisual <BrandTabVisual
designMD={designMD} designMD={designMD}
handleDesignChange={handleDesignChange} handleDesignChange={handleDesignChange}
onEditAsset={onEditAsset}
/> />
)} )}
{activeTab === 'typography' && ( {activeTab === 'typography' && (
@@ -195,6 +197,7 @@ export const BrandArchitecture: React.FC<BrandArchitectureProps> = ({ company, h
<BrandTabMedia <BrandTabMedia
designMD={designMD} designMD={designMD}
handleDesignChange={handleDesignChange} handleDesignChange={handleDesignChange}
onEditAsset={onEditAsset}
/> />
)} )}
+3 -3
View File
@@ -85,9 +85,9 @@ export const StudioProperties: React.FC<StudioPropertiesProps> = ({
const isImageMode = outputFormat === 'image'; const isImageMode = outputFormat === 'image';
return ( 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 */} {/* 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' ? ( {activeTool === 'transitions' ? (
<TransitionsPanel designMD={designMD} /> <TransitionsPanel designMD={designMD} />
) : (selectedElementIds && selectedElementIds.size >= 2) ? ( ) : (selectedElementIds && selectedElementIds.size >= 2) ? (
@@ -137,7 +137,7 @@ export const StudioProperties: React.FC<StudioPropertiesProps> = ({
{/* Layers panel — image mode only (replaces the hidden timeline) */} {/* Layers panel — image mode only (replaces the hidden timeline) */}
{isImageMode && ( {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 <ImageLayersPanel
timelineElements={timelineElements} timelineElements={timelineElements}
setTimelineElements={setTimelineElements} setTimelineElements={setTimelineElements}
+13 -3
View File
@@ -18,8 +18,8 @@ interface StudioWorkspaceProps {
durationInFrames: number; durationInFrames: number;
timelineElements?: TimelineElement[]; timelineElements?: TimelineElement[];
setTimelineElements?: React.Dispatch<React.SetStateAction<TimelineElement[]>>; setTimelineElements?: React.Dispatch<React.SetStateAction<TimelineElement[]>>;
aspectRatio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3'; aspectRatio: string;
setAspectRatio: (ratio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3') => void; setAspectRatio: (ratio: string) => void;
outputFormat?: 'video' | 'image'; outputFormat?: 'video' | 'image';
activeLayerId?: string; activeLayerId?: string;
/** Lifted zoom state for TopHeader integration */ /** 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 === '1:1') return { width: 1080, height: 1080 };
if (aspectRatio === '4:5') return { width: 1080, height: 1350 }; if (aspectRatio === '4:5') return { width: 1080, height: 1350 };
if (aspectRatio === '4:3') return { width: 1440, height: 1080 }; 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(); const dimensions = getDimensions();
+17 -8
View File
@@ -15,8 +15,10 @@ interface TopHeaderProps {
onZoomOut?: () => void; onZoomOut?: () => void;
onZoomReset?: () => void; onZoomReset?: () => void;
/** Aspect ratio controls */ /** Aspect ratio controls */
aspectRatio?: '16:9' | '1:1' | '9:16' | '4:5' | '4:3'; aspectRatio?: string;
onAspectRatioChange?: (ratio: '16:9' | '1:1' | '9:16' | '4:5' | '4:3') => void; onAspectRatioChange?: (ratio: string) => void;
disableAspectControls?: boolean;
titleOverride?: React.ReactNode;
} }
export const TopHeader: React.FC<TopHeaderProps> = ({ export const TopHeader: React.FC<TopHeaderProps> = ({
@@ -31,14 +33,19 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
onZoomReset, onZoomReset,
aspectRatio = '9:16', aspectRatio = '9:16',
onAspectRatioChange, onAspectRatioChange,
disableAspectControls,
titleOverride,
}) => { }) => {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const isStudio = currentStep === 'studio'; const isStudio = currentStep === 'studio';
return ( 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 */} {/* 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"> <div className="relative">
<button <button
onClick={() => setMenuOpen(!menuOpen)} 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"> <div className="bg-violet-600/20 p-1 rounded text-violet-400">
<LayoutTemplate size={14} /> <LayoutTemplate size={14} />
</div> </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>
</div> </div>
{/* Center: Zoom controls (only in studio) */} {/* Center: Zoom controls (only in studio) */}
{isStudio && onZoomIn && onZoomOut && ( {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 <button
onClick={(e) => { e.stopPropagation(); onZoomOut(); }} onClick={(e) => { e.stopPropagation(); onZoomOut(); }}
title="Zoom Out" title="Zoom Out"
@@ -118,7 +127,7 @@ export const TopHeader: React.FC<TopHeaderProps> = ({
</button> </button>
{/* Aspect ratio pills */} {/* Aspect ratio pills */}
{onAspectRatioChange && ( {onAspectRatioChange && !disableAspectControls && (
<> <>
<div className="w-px h-4 bg-neutral-700 mx-1" /> <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 => ( {(['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 */} {/* 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 */} {/* Express / Pro buttons — only on dashboard */}
{currentStep === 'dashboard' && onStartExpressBlank && ( {currentStep === 'dashboard' && onStartExpressBlank && (
<button <button
+18 -3
View File
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react'; 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 { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone'; import { FileDropZone } from '../ui/FileDropZone';
@@ -15,7 +15,7 @@ interface BrandTabMediaProps {
* All positioning, fit, duration, and blend controls live in the TemplateBuilder * All positioning, fit, duration, and blend controls live in the TemplateBuilder
* (per-template segment configuration), avoiding collisions. * (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) */ /** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */
const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => { const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => {
@@ -60,6 +60,8 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDe
handleDesignChange('introVideoUrl', ''); handleDesignChange('introVideoUrl', '');
handleDesignChange('introDurationFrames', 60); handleDesignChange('introDurationFrames', 60);
}} }}
onEdit={() => onEditAsset?.('introVideoUrl', designMD.introVideoUrl || '')}
showEdit={!!(designMD.introVideoUrl && onEditAsset)}
/> />
{/* ═══ Outro Video ═══ */} {/* ═══ Outro Video ═══ */}
@@ -76,6 +78,8 @@ export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ designMD, handleDe
handleDesignChange('outroVideoUrl', ''); handleDesignChange('outroVideoUrl', '');
handleDesignChange('outroDurationFrames', 60); handleDesignChange('outroDurationFrames', 60);
}} }}
onEdit={() => onEditAsset?.('outroVideoUrl', designMD.outroVideoUrl || '')}
showEdit={!!(designMD.outroVideoUrl && onEditAsset)}
/> />
{/* ═══ Brand Audio ═══ */} {/* ═══ Brand Audio ═══ */}
@@ -178,7 +182,9 @@ const VideoUploadSimple: React.FC<{
accentColor: string; accentColor: string;
onUrlChange: (url: string) => void; onUrlChange: (url: string) => void;
onClear: () => 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; const hasVideo = !!videoUrl && videoUrl.trim().length > 0;
return ( 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>
</div> </div>
+23 -8
View File
@@ -1,27 +1,33 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Settings2, ImageIcon } from 'lucide-react'; import { Settings2, ImageIcon, Wand2 } from 'lucide-react';
import { DesignMD } from '../../types'; import { DesignMD } from '../../types';
import { FileDropZone } from '../ui/FileDropZone'; import { FileDropZone } from '../ui/FileDropZone';
interface BrandTabVisualProps { interface BrandTabVisualProps {
designMD: DesignMD; designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void; handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
onEditAsset?: (type: keyof DesignMD, url: string) => void;
} }
export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({ export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
designMD, designMD,
handleDesignChange, handleDesignChange,
onEditAsset,
}) => { }) => {
const handleLogoFiles = useCallback((files: File[]) => { const handleLogoFiles = useCallback(async (files: File[]) => {
const file = files[0]; const file = files[0];
if (!file) return; if (!file) return;
const reader = new FileReader();
reader.onload = (event) => { try {
if (event.target?.result) { const formData = new FormData();
handleDesignChange('logoUrl', event.target.result as string); 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);
} }
};
reader.readAsDataURL(file);
}, [handleDesignChange]); }, [handleDesignChange]);
return ( return (
@@ -53,6 +59,15 @@ export const BrandTabVisual: React.FC<BrandTabVisualProps> = ({
label="Subir desde archivo" label="Subir desde archivo"
onFiles={handleLogoFiles} 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> </div>
</div> </div>
+40 -6
View File
@@ -14,8 +14,8 @@ interface ExportModalProps {
durationInFrames: number; durationInFrames: number;
brandVisibility?: { logo: boolean; frame: boolean; background: boolean }; brandVisibility?: { logo: boolean; frame: boolean; background: boolean };
outputFormat?: 'video' | 'image'; outputFormat?: 'video' | 'image';
/** Template aspect ratio — used to filter resolution presets */ aspectRatio?: string;
aspectRatio?: '9:16' | '16:9' | '1:1' | '4:5' | '4:3'; onAssetSaved?: (url: string) => void;
} }
const FORMAT_OPTIONS: { value: RenderFormat; label: string; icon: typeof Film; desc: string }[] = [ const FORMAT_OPTIONS: { value: RenderFormat; label: string; icon: typeof Film; desc: string }[] = [
@@ -53,6 +53,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
brandVisibility, brandVisibility,
outputFormat, outputFormat,
aspectRatio, aspectRatio,
onAssetSaved,
}) => { }) => {
const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue(); const { jobs, activeJobs, hasActiveJobs, isConnected, startExport, cancelJob, downloadJob } = useExportQueue();
@@ -68,7 +69,24 @@ export const ExportModal: React.FC<ExportModalProps> = ({
const filteredPresets = useMemo(() => { const filteredPresets = useMemo(() => {
if (!aspectRatio) return RESOLUTION_PRESETS; if (!aspectRatio) return RESOLUTION_PRESETS;
const matching = RESOLUTION_PRESETS.filter(p => p.ratio === aspectRatio); 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]); }, [aspectRatio]);
const [resIdx, setResIdx] = useState(0); const [resIdx, setResIdx] = useState(0);
@@ -94,6 +112,22 @@ export const ExportModal: React.FC<ExportModalProps> = ({
return FORMAT_OPTIONS; return FORMAT_OPTIONS;
}, [outputFormat]); }, [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 () => { const handleExport = async () => {
setIsExporting(true); setIsExporting(true);
try { try {
@@ -128,8 +162,8 @@ export const ExportModal: React.FC<ExportModalProps> = ({
<Download size={18} className="text-violet-400" /> <Download size={18} className="text-violet-400" />
</div> </div>
<div> <div>
<h2 className="text-sm font-bold text-white">Exportar</h2> <h2 className="text-sm font-bold text-white">{onAssetSaved ? 'Guardar Activo de Marca' : 'Exportar'}</h2>
<p className="text-[10px] text-neutral-500">Renderizar y descargar</p> <p className="text-[10px] text-neutral-500">{onAssetSaved ? 'Renderizar y aplicar cambios' : 'Renderizar y descargar'}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -284,7 +318,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
}`} }`}
> >
<Zap size={16} /> <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> <span className="text-[9px] opacity-60 font-mono">({estimatedSize})</span>
</button> </button>
@@ -13,7 +13,7 @@ import { FilterPresets } from '../ui/FilterPresets';
import { TextStylePresets } from '../ui/TextStylePresets'; import { TextStylePresets } from '../ui/TextStylePresets';
import { CollapsibleSection } from '../ui/CollapsibleSection'; import { CollapsibleSection } from '../ui/CollapsibleSection';
import { useColorHistory } from '../../hooks/useColorHistory'; import { useColorHistory } from '../../hooks/useColorHistory';
import { removeImageBackground } from '../../utils/backgroundRemoval';
interface ElementPropertiesPanelProps { interface ElementPropertiesPanelProps {
selectedElementId: string; selectedElementId: string;
setSelectedElementId: (id: string | null) => void; setSelectedElementId: (id: string | null) => void;
@@ -247,6 +247,7 @@ export const ElementPropertiesPanel: React.FC<ElementPropertiesPanelProps> = ({
const [showBorderEffects, setShowBorderEffects] = useState(false); const [showBorderEffects, setShowBorderEffects] = useState(false);
const [showShadow, setShowShadow] = useState(false); const [showShadow, setShowShadow] = useState(false);
const [showChromaKey, setShowChromaKey] = useState(false); const [showChromaKey, setShowChromaKey] = useState(false);
const [isRemovingBg, setIsRemovingBg] = useState(false);
const [copiedStyle, setCopiedStyle] = useState<Partial<TimelineElement> | null>(null); const [copiedStyle, setCopiedStyle] = useState<Partial<TimelineElement> | null>(null);
const { recentColors, addColor } = useColorHistory(); const { recentColors, addColor } = useColorHistory();
@@ -582,6 +583,32 @@ export const ElementPropertiesPanel: React.FC<ElementPropertiesPanelProps> = ({
> >
<Pipette size={10} /> Chroma <Pipette size={10} /> Chroma
</button> </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> </div>
{/* ── Chroma Key Controls ── */} {/* ── Chroma Key Controls ── */}
@@ -4,6 +4,7 @@ import { CollapsibleSection } from '../ui/CollapsibleSection';
import type { BradlyPlayerRef } from '../../engine/player'; import type { BradlyPlayerRef } from '../../engine/player';
import { EXPORT_PRESETS } from '../../config/constants'; import { EXPORT_PRESETS } from '../../config/constants';
import { TimelineElement, TimelineLayer } from '../../types'; import { TimelineElement, TimelineLayer } from '../../types';
import { useEditor } from '../../context/EditorContext';
import { ProjectStats } from '../ui/ProjectStats'; import { ProjectStats } from '../ui/ProjectStats';
import { QuickElementTemplates } from '../ui/QuickElementTemplates'; import { QuickElementTemplates } from '../ui/QuickElementTemplates';
import { BulkActionsBar } from '../ui/BulkActionsBar'; import { BulkActionsBar } from '../ui/BulkActionsBar';
@@ -32,6 +33,7 @@ export const GlobalSettingsPanel: React.FC<GlobalSettingsPanelProps> = ({
timelineElements, setTimelineElements, showGrid, setShowGrid, showSafeZone, setShowSafeZone, timelineElements, setTimelineElements, showGrid, setShowGrid, showSafeZone, setShowSafeZone,
onShowRenderHistory, layers, durationInFrames, fps, onShowRenderHistory, layers, durationInFrames, fps,
}) => { }) => {
const { editingBrandAsset } = useEditor();
// ═══ Export frame as PNG ═══ // ═══ Export frame as PNG ═══
const handleExportFrame = useCallback(() => { const handleExportFrame = useCallback(() => {
const player = playerRef?.current; const player = playerRef?.current;
@@ -277,11 +279,11 @@ export const GlobalSettingsPanel: React.FC<GlobalSettingsPanelProps> = ({
</button> </button>
{/* Render button */} {/* Render button */}
<button <button
title="Exportar Video" title={editingBrandAsset ? "Guardar Activo de Marca" : "Exportar Video"}
onClick={onExportClick} 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" 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> </button>
{/* Project Save/Load */} {/* Project Save/Load */}
<div className="flex gap-1.5 mt-1"> <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. * StudioEditor: The main editing view.
* Reads all state from EditorContext — no prop drilling needed. * 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 { const {
timelineElements, setTimelineElements, timelineElements, setTimelineElements,
layers, setLayers, layers, setLayers,
@@ -50,6 +50,7 @@ export const StudioEditor: React.FC = () => {
brandVisibility, setBrandVisibility, brandVisibility, setBrandVisibility,
activeAction, setActiveAction, activeAction, setActiveAction,
selectedElementIds, toggleElementSelection, clearSelection, selectedElementIds, toggleElementSelection, clearSelection,
editingBrandAsset,
} = useEditor(); } = useEditor();
// Panel state (replaces old activeTool for toolbar) // Panel state (replaces old activeTool for toolbar)
@@ -70,6 +71,7 @@ export const StudioEditor: React.FC = () => {
// Auto-save after 2s of inactivity // Auto-save after 2s of inactivity
useEffect(() => { useEffect(() => {
if (editingBrandAsset) return; // Disable autosave in brand asset mode
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(() => { autoSaveTimer.current = setTimeout(() => {
try { try {
@@ -88,6 +90,7 @@ export const StudioEditor: React.FC = () => {
// Auto-load on mount (only if no elements exist) // Auto-load on mount (only if no elements exist)
useEffect(() => { useEffect(() => {
if (editingBrandAsset) return; // Disable autoload in brand asset mode
try { try {
const saved = localStorage.getItem(AUTOSAVE_KEY); const saved = localStorage.getItem(AUTOSAVE_KEY);
if (!saved) return; if (!saved) return;
@@ -236,9 +239,9 @@ export const StudioEditor: React.FC = () => {
onElementDelete: handleDelete, onElementDelete: handleDelete,
onElementLock: handleLock, onElementLock: handleLock,
activeAction, activeAction,
brandVisibility, brandVisibility: editingBrandAsset ? { logo: false, frame: false, background: false } : brandVisibility,
outputFormat, 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 ( return (
<> <>
@@ -408,8 +411,10 @@ export const StudioEditor: React.FC = () => {
timelineElements={timelineElements} timelineElements={timelineElements}
layers={layers} layers={layers}
durationInFrames={durationInFrames} durationInFrames={durationInFrames}
brandVisibility={brandVisibility} brandVisibility={editingBrandAsset ? { logo: false, frame: false, background: false } : brandVisibility}
outputFormat={outputFormat} outputFormat={outputFormat}
aspectRatio={aspectRatio}
onAssetSaved={onAssetSaved}
/> />
{/* Shortcuts Overlay */} {/* Shortcuts Overlay */}
+11 -1
View File
@@ -11,7 +11,15 @@ interface StudioTopBarProps {
* Lives inside EditorProvider so it can access canvas zoom state. * Lives inside EditorProvider so it can access canvas zoom state.
*/ */
export const StudioTopBar: React.FC<StudioTopBarProps> = ({ setCurrentStep }) => { 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 ( return (
<TopHeader <TopHeader
@@ -24,6 +32,8 @@ export const StudioTopBar: React.FC<StudioTopBarProps> = ({ setCurrentStep }) =>
onZoomReset={() => setCanvasZoom(1)} onZoomReset={() => setCanvasZoom(1)}
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio} onAspectRatioChange={setAspectRatio}
disableAspectControls={!!editingBrandAsset}
titleOverride={titleOverride}
/> />
); );
}; };
+11 -3
View File
@@ -39,8 +39,8 @@ interface EditorState {
// Format // Format
outputFormat: 'video' | 'image'; outputFormat: 'video' | 'image';
setOutputFormat: (format: 'video' | 'image') => void; setOutputFormat: (format: 'video' | 'image') => void;
aspectRatio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3'; aspectRatio: string;
setAspectRatio: (ratio: '16:9' | '9:16' | '1:1' | '4:5' | '4:3') => void; setAspectRatio: (aspect: string) => void;
// Timeline controls // Timeline controls
timelineZoom: number; timelineZoom: number;
@@ -65,6 +65,9 @@ interface EditorState {
// Brand visibility toggles // Brand visibility toggles
brandVisibility: { logo: boolean; frame: boolean; background: boolean }; brandVisibility: { logo: boolean; frame: boolean; background: boolean };
setBrandVisibility: React.Dispatch<React.SetStateAction<{ logo: boolean; frame: boolean; background: boolean }>>; setBrandVisibility: React.Dispatch<React.SetStateAction<{ logo: boolean; frame: boolean; background: boolean }>>;
// Asset Editing Mode
editingBrandAsset: { type: keyof DesignMD; url: string } | null;
} }
const EditorContext = createContext<EditorState | null>(null); const EditorContext = createContext<EditorState | null>(null);
@@ -81,7 +84,9 @@ interface EditorProviderProps {
initialElements?: TimelineElement[]; initialElements?: TimelineElement[];
initialLayers?: TimelineLayer[]; initialLayers?: TimelineLayer[];
initialFormat?: 'video' | 'image'; initialFormat?: 'video' | 'image';
initialAspect?: string;
brandContent?: BrandContentPiece[]; brandContent?: BrandContentPiece[];
editingBrandAsset?: { type: keyof DesignMD; url: string } | null;
} }
export const EditorProvider: React.FC<EditorProviderProps> = ({ export const EditorProvider: React.FC<EditorProviderProps> = ({
@@ -90,7 +95,9 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({
initialElements = [], initialElements = [],
initialLayers = [{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' }], initialLayers = [{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' }],
initialFormat = 'video', initialFormat = 'video',
initialAspect = '9:16',
brandContent = [], brandContent = [],
editingBrandAsset = null,
}) => { }) => {
const [timelineElements, setTimelineElements] = useState<TimelineElement[]>(initialElements); const [timelineElements, setTimelineElements] = useState<TimelineElement[]>(initialElements);
const [layers, setLayers] = useState<TimelineLayer[]>(initialLayers); const [layers, setLayers] = useState<TimelineLayer[]>(initialLayers);
@@ -137,7 +144,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({
const [designMD, setDesignMD] = useState<DesignMD>(initialDesignMD); const [designMD, setDesignMD] = useState<DesignMD>(initialDesignMD);
const [textOverlay, setTextOverlay] = useState(''); const [textOverlay, setTextOverlay] = useState('');
const [outputFormat, setOutputFormat] = useState<'video' | 'image'>(initialFormat); const [outputFormat, setOutputFormat] = useState<'video' | 'image'>(initialFormat);
const [aspectRatio, setAspectRatio] = useState<'16:9' | '9:16' | '1:1' | '4:5' | '4:3'>('9:16'); const [aspectRatio, setAspectRatio] = useState<string>(initialAspect);
const [timelineZoom, setTimelineZoom] = useState<number>(1); const [timelineZoom, setTimelineZoom] = useState<number>(1);
const [timeUnit, setTimeUnit] = useState<'frames' | 'seconds'>('frames'); const [timeUnit, setTimeUnit] = useState<'frames' | 'seconds'>('frames');
const [canvasZoom, setCanvasZoom] = useState(1); const [canvasZoom, setCanvasZoom] = useState(1);
@@ -170,6 +177,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({
undo, redo, canUndo, canRedo, undo, redo, canUndo, canRedo,
brandContent, brandContent,
brandVisibility, setBrandVisibility, brandVisibility, setBrandVisibility,
editingBrandAsset,
}; };
return ( return (
+1 -1
View File
@@ -239,7 +239,7 @@ function BradlyPlayerInner<T>(
style={{ style={{
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: '#000', backgroundColor: 'transparent',
...style, ...style,
}} }}
className={className} className={className}
+1 -1
View File
@@ -85,7 +85,6 @@ export async function renderFrames(options: RenderFrameOptions): Promise<string[
`--window-size=${width},${height}`, `--window-size=${width},${height}`,
'--no-sandbox', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-setuid-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage', '--disable-dev-shm-usage',
], ],
}); });
@@ -149,6 +148,7 @@ export async function renderFrames(options: RenderFrameOptions): Promise<string[
type: imageFormat, type: imageFormat,
quality: imageFormat === 'jpeg' ? quality : undefined, quality: imageFormat === 'jpeg' ? quality : undefined,
clip: { x: 0, y: 0, width, height }, clip: { x: 0, y: 0, width, height },
omitBackground: imageFormat === 'png',
}); });
framePaths.push(framePath); framePaths.push(framePath);
+10 -2
View File
@@ -69,7 +69,7 @@ export const RenderPage: React.FC = () => {
<div style={{ <div style={{
width: '100vw', width: '100vw',
height: '100vh', height: '100vh',
background: '#000', background: 'transparent',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -87,8 +87,16 @@ export const RenderPage: React.FC = () => {
width: config.width, width: config.width,
height: config.height, height: config.height,
overflow: 'hidden', overflow: 'hidden',
background: '#000', background: 'transparent',
}}> }}>
<style>{`
html, body, #root {
background: transparent !important;
margin: 0;
padding: 0;
overflow: hidden;
}
`}</style>
<BradlyPlayer <BradlyPlayer
ref={playerRef} ref={playerRef}
component={BrandComposition} component={BrandComposition}
+1 -1
View File
@@ -557,7 +557,7 @@ export interface ExpressTemplate {
description: string; description: string;
category: 'social' | 'ad' | 'promo' | 'story' | 'announcement'; category: 'social' | 'ad' | 'promo' | 'story' | 'announcement';
icon: string; icon: string;
aspectRatio: '9:16' | '16:9' | '1:1' | '4:5' | '4:3'; aspectRatio: string;
format: 'video' | 'image'; format: 'video' | 'image';
/** Ordered list of scenes (the storyboard) */ /** Ordered list of scenes (the storyboard) */
scenes: ExpressScene[]; scenes: ExpressScene[];
+42
View File
@@ -0,0 +1,42 @@
import { uploadMedia } from './mediaUploader';
const API_BASE_URL = 'http://localhost:8000'; // Default port for the background-remover service
export async function removeImageBackground(imageSrc: string | Blob): Promise<string> {
try {
let sourceBlob: Blob;
if (typeof imageSrc === 'string') {
// Fetch the image as a Blob first
const res = await fetch(imageSrc);
if (!res.ok) throw new Error('Failed to fetch image source');
sourceBlob = await res.blob();
} else {
sourceBlob = imageSrc;
}
const formData = new FormData();
formData.append('file', sourceBlob, 'image_to_process.png');
console.log('[AI Background Removal] Enviando imagen al servicio Python local...');
const response = await fetch(`${API_BASE_URL}/api/v1/remove-image-background`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Error en el servicio de IA: ${response.statusText}`);
}
const imageBlob = await response.blob();
// Convertimos el blob devuelto por la IA en un archivo físico virtual
const file = new File([imageBlob], `removed-bg-${Date.now()}.png`, { type: 'image/png' });
// Subimos la imagen al servidor interno para tener una URL persistente (/api/media/...)
const result = await uploadMedia(file);
return result.url;
} catch (error) {
console.error('Error al remover el fondo con IA:', error);
throw new Error('No se pudo remover el fondo de la imagen.');
}
}
+32
View File
@@ -0,0 +1,32 @@
export async function detectMediaDimensionsAndAspect(
url: string,
type: 'video' | 'image'
): Promise<{ width: number; height: number; format: 'video' | 'image'; aspect: string }> {
return new Promise((resolve, reject) => {
if (type === 'image') {
const img = new Image();
img.onload = () => {
resolve({
width: img.width,
height: img.height,
format: 'image',
aspect: `${img.width}:${img.height}`,
});
};
img.onerror = () => reject(new Error('Failed to load image to detect dimensions.'));
img.src = url;
} else {
const video = document.createElement('video');
video.onloadedmetadata = () => {
resolve({
width: video.videoWidth,
height: video.videoHeight,
format: 'video',
aspect: `${video.videoWidth}:${video.videoHeight}`,
});
};
video.onerror = () => reject(new Error('Failed to load video to detect dimensions.'));
video.src = url;
}
});
}