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",
|
"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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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[];
|
||||||
|
|||||||
@@ -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