Refactor: remove AGPL imgly dependency and migrate background removal to python backend
This commit is contained in:
Generated
+13
-13
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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
@@ -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<Step>('dashboard');
|
||||
const [designMD, setDesignMD] = useState<DesignMD>(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<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) => {
|
||||
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<string, string>) => {
|
||||
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}
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<StudioTopBar setCurrentStep={setCurrentStep} />
|
||||
<StudioEditor />
|
||||
<StudioEditor onAssetSaved={handleAssetSaved} />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, [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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,8 +39,8 @@ interface EditorState {
|
||||
// Format
|
||||
outputFormat: 'video' | 'image';
|
||||
setOutputFormat: (format: 'video' | 'image') => void;
|
||||
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: (aspect: string) => void;
|
||||
|
||||
// Timeline controls
|
||||
timelineZoom: number;
|
||||
@@ -65,6 +65,9 @@ interface EditorState {
|
||||
// Brand visibility toggles
|
||||
brandVisibility: { 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);
|
||||
@@ -81,7 +84,9 @@ interface EditorProviderProps {
|
||||
initialElements?: TimelineElement[];
|
||||
initialLayers?: TimelineLayer[];
|
||||
initialFormat?: 'video' | 'image';
|
||||
initialAspect?: string;
|
||||
brandContent?: BrandContentPiece[];
|
||||
editingBrandAsset?: { type: keyof DesignMD; url: string } | null;
|
||||
}
|
||||
|
||||
export const EditorProvider: React.FC<EditorProviderProps> = ({
|
||||
@@ -90,7 +95,9 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({
|
||||
initialElements = [],
|
||||
initialLayers = [{ id: 'layer-1', name: 'Capa Gráfica 1', type: 'visual' }],
|
||||
initialFormat = 'video',
|
||||
initialAspect = '9:16',
|
||||
brandContent = [],
|
||||
editingBrandAsset = null,
|
||||
}) => {
|
||||
const [timelineElements, setTimelineElements] = useState<TimelineElement[]>(initialElements);
|
||||
const [layers, setLayers] = useState<TimelineLayer[]>(initialLayers);
|
||||
@@ -137,7 +144,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({
|
||||
const [designMD, setDesignMD] = useState<DesignMD>(initialDesignMD);
|
||||
const [textOverlay, setTextOverlay] = useState('');
|
||||
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 [timeUnit, setTimeUnit] = useState<'frames' | 'seconds'>('frames');
|
||||
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||
@@ -170,6 +177,7 @@ export const EditorProvider: React.FC<EditorProviderProps> = ({
|
||||
undo, redo, canUndo, canRedo,
|
||||
brandContent,
|
||||
brandVisibility, setBrandVisibility,
|
||||
editingBrandAsset,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -239,7 +239,7 @@ function BradlyPlayerInner<T>(
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000',
|
||||
backgroundColor: 'transparent',
|
||||
...style,
|
||||
}}
|
||||
className={className}
|
||||
|
||||
@@ -85,7 +85,6 @@ export async function renderFrames(options: RenderFrameOptions): Promise<string[
|
||||
`--window-size=${width},${height}`,
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
],
|
||||
});
|
||||
@@ -149,6 +148,7 @@ export async function renderFrames(options: RenderFrameOptions): Promise<string[
|
||||
type: imageFormat,
|
||||
quality: imageFormat === 'jpeg' ? quality : undefined,
|
||||
clip: { x: 0, y: 0, width, height },
|
||||
omitBackground: imageFormat === 'png',
|
||||
});
|
||||
|
||||
framePaths.push(framePath);
|
||||
|
||||
@@ -69,7 +69,7 @@ export const RenderPage: React.FC = () => {
|
||||
<div style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: '#000',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -87,8 +87,16 @@ export const RenderPage: React.FC = () => {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
overflow: 'hidden',
|
||||
background: '#000',
|
||||
background: 'transparent',
|
||||
}}>
|
||||
<style>{`
|
||||
html, body, #root {
|
||||
background: transparent !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
`}</style>
|
||||
<BradlyPlayer
|
||||
ref={playerRef}
|
||||
component={BrandComposition}
|
||||
|
||||
+1
-1
@@ -557,7 +557,7 @@ export interface ExpressTemplate {
|
||||
description: string;
|
||||
category: 'social' | 'ad' | 'promo' | 'story' | 'announcement';
|
||||
icon: string;
|
||||
aspectRatio: '9:16' | '16:9' | '1:1' | '4:5' | '4:3';
|
||||
aspectRatio: string;
|
||||
format: 'video' | 'image';
|
||||
/** Ordered list of scenes (the storyboard) */
|
||||
scenes: ExpressScene[];
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user