feat: AI Brand Voice Translator integration and Mesh Content fix

This commit is contained in:
2026-06-03 23:56:58 -05:00
parent ad8622e243
commit 0676e9f0a8
40 changed files with 3186 additions and 1481 deletions
+169 -372
View File
@@ -1,388 +1,185 @@
import React, { useCallback } from 'react';
import { Film, Volume2, Music, X, Upload, Wand2, Maximize2, Minimize2, Move, Pipette } from 'lucide-react';
import { DesignMD, CompanyProfile } from '../../types';
import { FileDropZone } from '../ui/FileDropZone';
import React, { useState } from 'react';
import { Film } from 'lucide-react';
import { DesignMD, CompanyProfile, BrandAsset } from '../../types';
import { UnifiedMediaLibrary, UnifiedMediaItem } from '../content-grid/UnifiedMediaLibrary';
type AssetFilter = 'all' | 'image' | 'video' | 'audio';
type SourceFilter = 'all' | 'uploaded' | 'generated';
interface BrandTabMediaProps {
company: CompanyProfile;
designMD: DesignMD;
handleDesignChange: (key: keyof DesignMD, value: string | number | string[] | boolean) => void;
handleDesignChange: (key: keyof DesignMD, value: any) => void;
onEditAsset?: (type: keyof DesignMD, url: string) => void;
onSelectAsset?: (asset: BrandAsset) => void;
selectedAssetId?: string;
}
/**
* BrandTabMedia — Upload-only panel for brand video/audio assets.
*
* Only handles uploading the intro video, outro video, and brand audio.
* All positioning, fit, duration, and blend controls live in the TemplateBuilder
* (per-template segment configuration), avoiding collisions.
*/
export const BrandTabMedia: React.FC<BrandTabMediaProps & { onEditAsset?: (type: keyof DesignMD, url: string) => void }> = ({ company, designMD, handleDesignChange, onEditAsset }) => {
export const BrandTabMedia: React.FC<BrandTabMediaProps> = ({ company, designMD, handleDesignChange, onSelectAsset, selectedAssetId }) => {
const assets = designMD.brandAssets || [];
const [uploadingBrandAsset, setUploadingBrandAsset] = useState(false);
const [uploadingGenerated, setUploadingGenerated] = useState(false);
const [filterType, setFilterType] = useState<AssetFilter>('all');
const [filterSource, setFilterSource] = useState<SourceFilter>('all');
const [refreshTrigger, setRefreshTrigger] = useState(0);
/** Auto-detect video duration and store it in DesignMD (for BrandPreview playback) */
const probeVideoDuration = useCallback((url: string, key: 'introDurationFrames' | 'outroDurationFrames') => {
if (!url) return;
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
if (video.duration && isFinite(video.duration)) {
const frames = Math.round(video.duration * 30); // 30fps
handleDesignChange(key, Math.max(15, Math.min(300, frames)));
const handleBrandAssetFiles = async (files: File[]) => {
if (files.length === 0) return;
setUploadingBrandAsset(true);
try {
let currentWorkspacePath = '';
if (window.electronAPI) {
currentWorkspacePath = await window.electronAPI.fs.getWorkspacePath();
}
video.remove();
};
video.onerror = () => video.remove();
video.src = url;
}, [handleDesignChange]);
const newAssets = [...assets];
for (const file of files) {
const baseName = file.name.split('.')[0].replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
let uniqueId = baseName;
let counter = 1;
while (newAssets.some(a => a.id === uniqueId)) {
uniqueId = `${baseName}-${counter}`;
counter++;
}
const formData = new FormData();
formData.append('file', file);
let type: 'image' | 'video' | 'audio' = 'image';
let subfolder = 'files/images';
if (file.type.startsWith('video/')) {
type = 'video';
subfolder = 'files/videos';
} else if (file.type.startsWith('audio/')) {
type = 'audio';
subfolder = 'files/audios';
}
if (currentWorkspacePath && company.id) {
formData.append('brandId', company.id);
formData.append('workspacePath', currentWorkspacePath);
formData.append('subfolder', subfolder);
}
const endpoint = (currentWorkspacePath && company.id) ? '/api/upload/brand' : '/api/upload';
const res = await fetch(endpoint, { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
if (data.url) {
newAssets.push({
id: uniqueId,
type,
url: data.url,
path: data.path
});
}
}
handleDesignChange('brandAssets', newAssets);
} catch (err) {
console.error('Asset upload failed:', err);
alert('Ocurrió un error al subir los archivos.');
} finally {
setUploadingBrandAsset(false);
}
};
const handleDeleteAsset = (id: string) => {
if (confirm(`¿Estás seguro de que deseas eliminar el asset "${id}"?`)) {
handleDesignChange('brandAssets', assets.filter(a => a.id !== id));
}
};
const handleRenameAsset = (oldId: string, newId: string) => {
if (newId !== oldId && assets.some(a => a.id === newId)) {
alert('Este identificador ya existe.');
return;
}
const newAssets = assets.map(a => {
if (a.id === oldId) return { ...a, id: newId };
return a;
});
handleDesignChange('brandAssets', newAssets);
};
const onSelectUnified = (item: UnifiedMediaItem) => {
if (!onSelectAsset) return;
// Map UnifiedMediaItem back to BrandAsset if it's uploaded
if (item.source === 'uploaded') {
const asset = assets.find(a => a.id === item.id);
if (asset) onSelectAsset(asset);
} else {
// It's generated. We can construct a mock BrandAsset to show in preview
onSelectAsset({
id: item.name || item.id,
type: item.type,
path: item.path,
url: item.url,
date: item.date
});
}
};
return (
<div className="space-y-5">
{/* Section title */}
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2 mb-1">
<Film size={16} className="text-violet-400" />
Archivos de Video y Audio
</h3>
<p className="text-xs text-neutral-500 leading-relaxed">
Sube los videos y audio de tu marca. La posición, duración y estilo se configuran en cada plantilla.
</p>
<div className="flex flex-col h-full space-y-6">
{/* Encabezado */}
<div className="flex items-center justify-between shrink-0">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2 mb-1">
<Film size={16} className="text-violet-400" />
Librería Unificada
</h3>
<p className="text-xs text-neutral-500 leading-relaxed max-w-xl">
Gestiona tus archivos base de marca y tus renders generados en un solo lugar.
</p>
</div>
<div className="flex gap-2">
<select
value={filterSource}
onChange={(e) => setFilterSource(e.target.value as SourceFilter)}
className="bg-neutral-900 border border-neutral-800 text-neutral-300 text-xs rounded-md px-2 py-1 outline-none"
>
<option value="all">Todos los orígenes</option>
<option value="uploaded">Assets Base (Subidos)</option>
<option value="generated">Renders Generados</option>
</select>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as AssetFilter)}
className="bg-neutral-900 border border-neutral-800 text-neutral-300 text-xs rounded-md px-2 py-1 outline-none"
>
<option value="all">Todos los tipos</option>
<option value="image">Imágenes</option>
<option value="video">Videos</option>
<option value="audio">Audio</option>
</select>
</div>
</div>
{/* ═══ Intro Video ═══ */}
<VideoUploadSimple
company={company}
label="Video de Cabezote (Intro)"
description="Se usará automáticamente en plantillas que incluyan segmento de intro de marca"
videoUrl={designMD.introVideoUrl || ''}
accentColor="#10b981"
onUrlChange={(url) => {
handleDesignChange('introVideoUrl', url);
if (url) probeVideoDuration(url, 'introDurationFrames');
}}
onClear={() => {
handleDesignChange('introVideoUrl', '');
handleDesignChange('introDurationFrames', 60);
}}
onEdit={() => onEditAsset?.('introVideoUrl', designMD.introVideoUrl || '')}
showEdit={!!(designMD.introVideoUrl && onEditAsset)}
fit={designMD.introVideoFit}
onFitChange={(fit) => handleDesignChange('introVideoFit', fit)}
bgColor={designMD.introVideoBgColor}
onBgColorChange={(color) => handleDesignChange('introVideoBgColor', color ?? '')}
/>
{/* ═══ Outro Video ═══ */}
<VideoUploadSimple
company={company}
label="Video de Cierre (Outro)"
description="Se usará automáticamente en plantillas que incluyan segmento de outro de marca"
videoUrl={designMD.outroVideoUrl || ''}
accentColor="#f43f5e"
onUrlChange={(url) => {
handleDesignChange('outroVideoUrl', url);
if (url) probeVideoDuration(url, 'outroDurationFrames');
}}
onClear={() => {
handleDesignChange('outroVideoUrl', '');
handleDesignChange('outroDurationFrames', 60);
}}
onEdit={() => onEditAsset?.('outroVideoUrl', designMD.outroVideoUrl || '')}
showEdit={!!(designMD.outroVideoUrl && onEditAsset)}
fit={designMD.outroVideoFit}
onFitChange={(fit) => handleDesignChange('outroVideoFit', fit)}
bgColor={designMD.outroVideoBgColor}
onBgColorChange={(color) => handleDesignChange('outroVideoBgColor', color ?? '')}
/>
{/* ═══ Brand Audio ═══ */}
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-neutral-300 flex items-center gap-2">
<Music size={14} className="text-violet-400" />
Música / Jingle de Marca
</label>
{designMD.brandAudioUrl && (
<button
onClick={() => handleDesignChange('brandAudioUrl', '')}
title="Quitar audio de marca"
className="text-neutral-500 hover:text-rose-400 p-1 rounded transition-colors"
>
<X size={14} />
</button>
)}
</div>
<p className="text-[11px] text-neutral-500 -mt-1">
Se incluirá como pista de fondo en plantillas de video
</p>
<div className="flex gap-3 items-start">
{/* Preview */}
<div className="w-14 h-14 rounded-lg bg-neutral-950 border border-neutral-800 flex items-center justify-center shrink-0">
{designMD.brandAudioUrl ? (
<div className="flex items-end gap-0.5 h-6">
{[3, 5, 4, 6, 3].map((h, i) => (
<div
key={i}
className="w-1 bg-violet-500 rounded-full animate-pulse"
style={{ height: `${h * 3}px`, animationDelay: `${i * 0.15}s` }}
/>
))}
</div>
) : (
<Music size={20} className="text-neutral-600" />
)}
</div>
{/* Upload controls */}
<div className="flex-1 space-y-2">
<input
type="text"
value={designMD.brandAudioUrl || ''}
onChange={(e) => handleDesignChange('brandAudioUrl', e.target.value)}
className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://audio.mp3"
/>
<FileDropZone
compact
accept="audio/*"
label="Subir audio"
onFiles={async (files) => {
let workspacePath = '';
if (window.electronAPI) {
workspacePath = await window.electronAPI.fs.getWorkspacePath();
}
const formData = new FormData();
formData.append('file', files[0]);
try {
let res;
if (workspacePath && company.id) {
formData.append('brandId', company.id);
formData.append('workspacePath', workspacePath);
res = await fetch('/api/upload/brand', { method: 'POST', body: formData });
} else {
res = await fetch('/api/upload', { method: 'POST', body: formData });
}
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
if (data.url) handleDesignChange('brandAudioUrl', data.url);
} catch (err) {
console.error('Audio upload failed:', err);
}
}}
/>
</div>
</div>
{/* Volume slider */}
{designMD.brandAudioUrl && (
<div className="flex items-center gap-3 pt-1">
<Volume2 size={12} className="text-neutral-500 shrink-0" />
<span className="text-[10px] text-neutral-500 shrink-0">Volumen:</span>
<input
type="range"
min="0"
max="100"
value={Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}
onChange={(e) => handleDesignChange('brandAudioVolume', parseInt(e.target.value) / 100)}
className="flex-1 h-1 bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-violet-500"
/>
<span className="text-[10px] font-mono text-violet-300 bg-neutral-800 px-1.5 py-0.5 rounded shrink-0">
{Math.round((designMD.brandAudioVolume ?? 0.8) * 100)}%
</span>
</div>
)}
{/* Unified Media Library as Global Drop Zone */}
<div className="flex-1 overflow-hidden">
<UnifiedMediaLibrary
brandId={company.id}
brandAssets={assets}
refreshTrigger={refreshTrigger}
onDeleteAsset={handleDeleteAsset}
onRenameAsset={handleRenameAsset}
onSelect={onSelectUnified}
selectedPath={selectedAssetId}
filterType={filterType}
filterSource={filterSource}
draggable={false} // In this view, dragging is not needed for the timeline
onDropFiles={handleBrandAssetFiles}
isUploading={uploadingBrandAsset}
/>
</div>
</div>
);
};
/* ── Simple Video Upload Card ── */
const VideoUploadSimple: React.FC<{
company: CompanyProfile;
label: string;
description: string;
videoUrl: string;
accentColor: string;
onUrlChange: (url: string) => void;
onClear: () => void;
onEdit?: () => void;
showEdit?: boolean;
fit?: 'cover' | 'contain' | 'fill';
onFitChange?: (fit: 'cover' | 'contain' | 'fill') => void;
bgColor?: string | null;
onBgColorChange?: (color: string | null) => void;
}> = ({ company, label, description, videoUrl, accentColor, onUrlChange, onClear, onEdit, showEdit, fit = 'cover', onFitChange, bgColor, onBgColorChange }) => {
const hasVideo = !!videoUrl && videoUrl.trim().length > 0;
const colorInputRef = React.useRef<HTMLInputElement>(null);
return (
<div className="bg-neutral-900/50 border border-neutral-800 rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-neutral-300 flex items-center gap-2">
<Film size={14} style={{ color: accentColor }} />
{label}
</label>
{hasVideo && (
<button
onClick={onClear}
title={`Quitar ${label}`}
className="text-neutral-500 hover:text-rose-400 p-1 rounded transition-colors"
>
<X size={14} />
</button>
)}
</div>
<p className="text-[11px] text-neutral-500 -mt-1">{description}</p>
<div className="flex gap-3 items-start">
{/* Video Preview */}
<div className="w-28 h-20 rounded-lg overflow-hidden bg-neutral-950 border border-neutral-800 shrink-0 flex items-center justify-center">
{hasVideo ? (
<video
src={videoUrl}
className="w-full h-full object-cover"
muted
playsInline
onMouseEnter={(e) => (e.target as HTMLVideoElement).play().catch(() => {})}
onMouseLeave={(e) => {
const v = e.target as HTMLVideoElement;
v.pause();
v.currentTime = 0;
}}
/>
) : (
<div className="text-neutral-600 flex flex-col items-center gap-1">
<Upload size={18} style={{ color: `${accentColor}60` }} />
<span className="text-[9px]">Sin video</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex-1 space-y-2">
<input
type="text"
value={videoUrl}
onChange={(e) => onUrlChange(e.target.value)}
className="bg-neutral-950 text-[11px] rounded-lg px-3 py-2 w-full border border-neutral-800 outline-none focus:border-violet-500/50 focus:ring-1 focus:ring-violet-500/50 transition-all font-mono text-white"
placeholder="https://video.mp4"
/>
<FileDropZone
compact
accept="video/*"
label="Subir archivo"
onFiles={async (files) => {
let workspacePath = '';
if (window.electronAPI) {
workspacePath = await window.electronAPI.fs.getWorkspacePath();
}
const formData = new FormData();
formData.append('file', files[0]);
try {
let res;
if (workspacePath && company.id) {
formData.append('brandId', company.id);
formData.append('workspacePath', workspacePath);
res = await fetch('/api/upload/brand', { method: 'POST', body: formData });
} else {
res = await fetch('/api/upload', { method: 'POST', body: formData });
}
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
if (data.url) onUrlChange(data.url);
} catch (err) {
console.error('Video upload failed:', err);
}
}}
/>
{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>
{/* Status badge */}
{hasVideo && (
<div className="flex flex-col gap-3 pt-1 border-t border-neutral-800/50">
<div className="flex items-center gap-1">
<span className="text-[10px] text-neutral-500 mr-2">Ajuste de video:</span>
{([
{ key: 'cover' as const, label: 'Cover', icon: <Maximize2 size={10} />, tip: 'Llenar pantalla' },
{ key: 'contain' as const, label: 'Contain', icon: <Minimize2 size={10} />, tip: 'Mostrar completo' },
{ key: 'fill' as const, label: 'Fill', icon: <Move size={10} />, tip: 'Estirar' },
]).map(opt => (
<button
key={opt.key}
onClick={() => onFitChange?.(opt.key)}
title={opt.tip}
className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-[9px] font-medium transition-all border ${
fit === opt.key
? `border-[${accentColor}]/50 bg-[${accentColor}]/15 text-[${accentColor}]`
: 'border-neutral-800 bg-neutral-900 text-neutral-400 hover:text-neutral-200'
}`}
style={fit === opt.key ? { borderColor: `${accentColor}50`, backgroundColor: `${accentColor}15`, color: accentColor } : {}}
>
{opt.icon} {opt.label}
</button>
))}
</div>
{fit === 'contain' && onBgColorChange && (
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-neutral-500 mr-2">Color de fondo:</span>
<button
onClick={() => colorInputRef.current?.click()}
title={bgColor ? `Color: ${bgColor}` : 'Seleccionar color de fondo'}
className="w-6 h-6 rounded border border-neutral-700 hover:border-neutral-500 transition-colors overflow-hidden flex items-center justify-center shrink-0"
style={{
backgroundColor: bgColor || undefined,
...(!bgColor ? {
backgroundImage: 'linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%)',
backgroundSize: '8px 8px',
backgroundPosition: '0 0, 0 4px, 4px -4px, -4px 0px',
} : {}),
}}
>
{!bgColor && <Pipette size={10} className="text-neutral-400" />}
</button>
<input
ref={colorInputRef}
type="color"
value={bgColor || '#000000'}
onChange={(e) => onBgColorChange(e.target.value)}
className="sr-only"
tabIndex={-1}
/>
<button
onClick={() => onBgColorChange(null)}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[9px] font-medium transition-all border ${
!bgColor
? `border-[${accentColor}]/50 bg-[${accentColor}]/15 text-[${accentColor}]`
: 'border-neutral-800 bg-neutral-900 text-neutral-400 hover:text-neutral-200'
}`}
style={!bgColor ? { borderColor: `${accentColor}50`, backgroundColor: `${accentColor}15`, color: accentColor } : {}}
>
<X size={10} /> Transparente
</button>
{bgColor && <span className="text-[9px] text-neutral-500 font-mono ml-1">{bgColor}</span>}
</div>
)}
</div>
)}
</div>
);
};