diff --git a/package-lock.json b/package-lock.json index 69f05ca..9b7a49b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "electron-vite": "^5.0.0", "esbuild": "^0.25.0", "tailwindcss": "^4.1.14", - "tsx": "^4.21.0", + "tsx": "^4.22.4", "typescript": "~5.8.2", "vite": "^6.2.3" } @@ -4436,15 +4436,6 @@ "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": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -10576,9 +10567,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", - "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -11762,6 +11753,15 @@ "funding": { "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" + } } } } diff --git a/package.json b/package.json index 8e786fb..6f2ffc9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", "@google/genai": "^2.4.0", - "puppeteer-core": "^24.9.0", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", @@ -34,6 +33,7 @@ "motion": "^12.23.24", "multer": "^2.1.1", "papaparse": "^5.5.3", + "puppeteer-core": "^24.9.0", "react": "^19.0.1", "react-dom": "^19.0.1", "vite": "^6.2.3" @@ -52,7 +52,7 @@ "electron-vite": "^5.0.0", "esbuild": "^0.25.0", "tailwindcss": "^4.1.14", - "tsx": "^4.21.0", + "tsx": "^4.22.4", "typescript": "~5.8.2", "vite": "^6.2.3" } diff --git a/services/background-remover/Dockerfile b/services/background-remover/Dockerfile new file mode 100644 index 0000000..4343283 --- /dev/null +++ b/services/background-remover/Dockerfile @@ -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"] diff --git a/services/background-remover/docker-compose.yml b/services/background-remover/docker-compose.yml new file mode 100644 index 0000000..027105c --- /dev/null +++ b/services/background-remover/docker-compose.yml @@ -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: diff --git a/services/background-remover/main.py b/services/background-remover/main.py new file mode 100644 index 0000000..d30c837 --- /dev/null +++ b/services/background-remover/main.py @@ -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)) diff --git a/services/background-remover/requirements.txt b/services/background-remover/requirements.txt new file mode 100644 index 0000000..08cfaaa --- /dev/null +++ b/services/background-remover/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +python-multipart==0.0.9 +backgroundremover==0.2.2 diff --git a/src/App.tsx b/src/App.tsx index ffe3b29..e4f428d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ import { ProductionForm } from './components/dashboard/ProductionForm'; import { StudioEditor } from './components/studio/StudioEditor'; import { ExpressEditor } from './components/express/ExpressEditor'; 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 { useCustomTooltips } from './hooks/useCustomTooltips'; import { ToastProvider } from './components/ui/ToastProvider'; @@ -17,6 +17,7 @@ import { TemplateBuilder } from './components/express/builder/TemplateBuilder'; import { EXPRESS_TEMPLATES } from './config/expressTemplates'; import { compileExpressToTimeline } from './utils/expressCompiler'; import { FullscreenToggle } from './components/ui/FullscreenToggle'; +import { detectMediaDimensionsAndAspect } from './utils/mediaDimensions'; type Step = 'dashboard' | 'brand' | 'studio' | 'express' | 'content-grid' | 'template-builder' | 'production-form'; @@ -41,6 +42,7 @@ export default function App() { const [currentStep, setCurrentStep] = useState('dashboard'); const [designMD, setDesignMD] = useState(DEFAULT_DESIGN_MD); const [outputFormat, setOutputFormat] = useState<'video' | 'image'>('video'); + const [editingBrandAsset, setEditingBrandAsset] = useState<{ type: keyof DesignMD; url: string } | null>(null); // Global templates (decoupled from brands) — persisted const [globalTemplates, setGlobalTemplates] = useState(() => { @@ -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) => { if (companyId) setCurrentCompanyId(companyId); if (projectId !== undefined) setCurrentProjectId(projectId); @@ -158,7 +134,57 @@ export default function App() { 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(() => { setCurrentCompanyId(null); setDesignMD(DEFAULT_DESIGN_MD); @@ -183,14 +209,12 @@ export default function App() { enterStudio(DEFAULT_DESIGN_MD, 'video', initialElements, initialLayers, undefined, null); }, []); - // ── Production flow: template × brand → form → editor ── const handleGenerate = useCallback((template: ExpressTemplate, brand: CompanyProfile) => { setProductionTemplate(template); setProductionBrand(brand); setCurrentStep('production-form'); }, []); - // ── Template management (edit / duplicate / delete) ── const handleEditTemplate = useCallback((template: ExpressTemplate) => { setEditingGlobalTemplate(template); setTemplateBuilderFormat(template.format); @@ -215,7 +239,6 @@ export default function App() { const handleProducePro = useCallback((fieldData: Record) => { if (!productionTemplate || !productionBrand) return; - // Compile template + brand + fieldData → TimelineElement[] const compiled = compileExpressToTimeline(productionTemplate, fieldData, productionBrand.design, productionBrand); enterStudio(productionBrand.design, productionTemplate.format, compiled.elements, compiled.layers, productionBrand.id, null); }, [productionTemplate, productionBrand]); @@ -313,6 +336,7 @@ export default function App() { designMD={designMD} handleDesignChange={handleDesignChange} onContinue={() => setCurrentStep('dashboard')} + onEditAsset={(type, url) => handleEditAsset({ type, url })} /> )} @@ -359,11 +383,13 @@ export default function App() { initialElements={studioInitialElements} initialLayers={studioInitialLayers} initialFormat={outputFormat} - brandContent={companies.find(c => c.id === currentCompanyId)?.brandContent} + initialAspect={templateBuilderAspect} + brandContent={currentCompanyId ? (getContentForCompany(currentCompanyId).pieces || []) : []} + editingBrandAsset={editingBrandAsset} >
- +
)} diff --git a/src/components/BrandArchitecture.tsx b/src/components/BrandArchitecture.tsx index 43daedf..5682d30 100644 --- a/src/components/BrandArchitecture.tsx +++ b/src/components/BrandArchitecture.tsx @@ -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 = ({ company, handleCompanyChange, designMD, handleDesignChange, onContinue }) => { +export const BrandArchitecture: React.FC = ({ 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('general'); @@ -186,6 +187,7 @@ export const BrandArchitecture: React.FC = ({ company, h )} {activeTab === 'typography' && ( @@ -195,6 +197,7 @@ export const BrandArchitecture: React.FC = ({ company, h )} diff --git a/src/components/StudioProperties.tsx b/src/components/StudioProperties.tsx index 0b8e0a7..4369e93 100644 --- a/src/components/StudioProperties.tsx +++ b/src/components/StudioProperties.tsx @@ -85,9 +85,9 @@ export const StudioProperties: React.FC = ({ const isImageMode = outputFormat === 'image'; return ( -