/* Tab: Crear Video - flujo en 3 pasos (actualizado) */ // Separador usado para unir Intro + Resto en un único texto de transcripción cuando // el modo es "Avatar + Imágenes" con guion propio (debe coincidir con el backend). const AVATAR_INTRO_DELIMITER = "\n\n<<>>\n\n"; function CreateVideoTab({ channels, onGenerated, initialMode, lockedMode }) { const activeChannels = channels.filter((c) => c.active !== false); // Modo de vídeo. `lockedMode` = página dedicada (Imágenes IA / Personaje / Clips de // stock): el selector de modo se oculta. Heygen Ready ("avatar") está eliminado. const MODES = ["imagenes_ia", "personaje", "stock_clips"]; const [narrationMode, setNarrationMode] = React.useState( MODES.includes(lockedMode) ? lockedMode : MODES.includes(initialMode) ? initialMode : "imagenes_ia" ); // imagenes_ia | personaje | stock_clips // Sub-modo cuando es "Generar con IA" const [promptMode, setPromptMode] = React.useState("creation"); // creation | reference | adaptation // Sub-modo cuando narrationMode === "imagenes_ia" | "avatar": de dónde sale el guion const [scriptSource, setScriptSource] = React.useState("ai"); // ai | own // Sub-modo cuando narrationMode === "avatar" (etiqueta visible: "Heygen Ready") const [heygenSubMode, setHeygenSubMode] = React.useState("con_imagenes"); // con_imagenes | split_screen | con_personaje // Sub-modo cuando narrationMode === "stock_clips": de dónde sale el guion const [stockClipsSubMode, setStockClipsSubMode] = React.useState("normal"); // normal | split_avatar // "Usar personaje en el contenido": compartido entre "Heygen con imágenes" y "Split Screen". const [useCharacterInContent, setUseCharacterInContent] = React.useState(true); const [zoomAvatarHookIntroFx, setZoomAvatarHookIntroFx] = React.useState(false); const [promptTitle, setPromptTitle] = React.useState(""); const [refNarration, setRefNarration] = React.useState(""); const [script, setScript] = React.useState(""); const [scriptResto, setScriptResto] = React.useState(""); // solo modo avatar + guion propio const [scriptAudio, setScriptAudio] = React.useState(null); const [scriptVideo, setScriptVideo] = React.useState(null); // solo modo avatar + "Heygen con imágenes"/"Split Screen" // Avatar + Imágenes: look concreto del avatar del canal a usar en este vídeo const [heygenLooks, setHeygenLooks] = React.useState([]); const [loadingHeygenLooks, setLoadingHeygenLooks] = React.useState(false); const [heygenLookId, setHeygenLookId] = React.useState(""); // Fuente efectiva de guion (colapsa "imagenes_ia"/"avatar"/"personaje" a su sub-modo real). // En "avatar" (Heygen Ready), "con_imagenes" y "split_screen" siempre parten de un // vídeo subido; solo "con_personaje" usa el picker ai/own de siempre. const scriptKind = narrationMode === "avatar" ? (heygenSubMode === "con_personaje" ? (scriptSource === "own" ? "script" : "prompt") : "video") : narrationMode === "stock_clips" && stockClipsSubMode === "split_avatar" ? "video" : (narrationMode === "imagenes_ia" || narrationMode === "personaje" || narrationMode === "stock_clips") ? (scriptSource === "own" ? "script" : "prompt") : narrationMode; // "prompt" | "script" | "video" const isAudioDriven = scriptKind === "script" || scriptKind === "video"; // Paso 2: montaje — opciones disponibles const [assemblyMode, setAssemblyMode] = React.useState("story_images"); // story_images | long_1image const [loopImages, setLoopImages] = React.useState(false); const [hookMode, setHookMode] = React.useState(""); // "" | "motion" | "pexels" const [imageCadence, setImageCadence] = React.useState(20); // 10 | 20 | 30 (solo modo avatar) const [hookIntroImages, setHookIntroImages] = React.useState(false); // Hook Intro Imagen (solo modo avatar) const [hookIntroImageCount, setHookIntroImageCount] = React.useState(2); // 1 | 2 | 3 // Paso 3: config const [channelId, setChannelId] = React.useState(""); const [durationBucket, setDurationBucket] = React.useState("regular"); // short|regular|medium|long|xlong const [kenBurns, setKenBurns] = React.useState(true); // Paso 4: transiciones (opcional, apagado por defecto) const [transitions, setTransitions] = React.useState(false); // Paso 5: música de fondo (opcional, apagado por defecto) const [musicEnabled, setMusicEnabled] = React.useState(false); const [availableMusicFolders, setAvailableMusicFolders] = React.useState([]); const [selectedMusicFolderIds, setSelectedMusicFolderIds] = React.useState([]); const [musicVolumeDb, setMusicVolumeDb] = React.useState(-28); // Velocidad de la voz narrada (solo aplica cuando el guion se genera con IA y ai33 la narra) const [voiceSpeed, setVoiceSpeed] = React.useState(1.0); // Modo "Clips de stock": usar también fotos/ilustraciones de Pixabay, o solo vídeo const [stockUseImages, setStockUseImages] = React.useState(true); // Modo "Imágenes Personaje": animar las primeras 5 imágenes a vídeo (LTX image-to-video) const [animateFirstImages, setAnimateFirstImages] = React.useState(false); const [nanoBananaVersion, setNanoBananaVersion] = React.useState("v1"); // v1 | v2 | pro const [generating, setGenerating] = React.useState(false); const [progress, setProgress] = React.useState(0); const [stepIdx, setStepIdx] = React.useState(-1); const [uploadPct, setUploadPct] = React.useState(0); const [summaryOpen, setSummaryOpen] = React.useState(false); const steps = [ { key: "script", label: "Guion", tool: "Claude Sonnet", dur: 14 }, { key: "review_script", label: "Revisión de guion", tool: "Tú apruebas", dur: 0 }, { key: "voice", label: "Voz narrada", tool: "ai33", dur: 18 }, { key: "scenes", label: "Storyboard", tool: "Claude · escenas", dur: 10 }, { key: "images", label: "Imágenes", tool: "Nanobanana", dur: 26 }, { key: "review_images", label: "Revisión de imágenes", tool: "Tú apruebas", dur: 0 }, { key: "assembly", label: "Montaje", tool: "FFmpeg", dur: 12 }, ]; const selectedChannel = activeChannels.find((c) => c.id === channelId) ?? null; const _videoCost = isAudioDriven ? 250 : ({ short: 250, regular: 500, medium: 1200 }[durationBucket] || 250); const loopApplies = false; const [genError, setGenError] = React.useState(""); // Al elegir canal (en modo avatar) cargamos los looks de su personaje HeyGen React.useEffect(() => { setHeygenLookId(""); setHeygenLooks([]); if (narrationMode !== "avatar" || !selectedChannel || !selectedChannel.heygenGroupId) return; setLoadingHeygenLooks(true); fetch(`/api/heygen/avatar-groups/${selectedChannel.heygenGroupId}/looks`, { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => setHeygenLooks(d.looks || [])) .catch(() => setHeygenLooks([])) .finally(() => setLoadingHeygenLooks(false)); }, [narrationMode, selectedChannel && selectedChannel.heygenGroupId]); // Carpetas de música de fondo del usuario (para el Paso 5) React.useEffect(() => { fetch("/api/music/folders", { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => setAvailableMusicFolders(d.folders || [])) .catch(() => setAvailableMusicFolders([])); }, []); function toggleMusicFolder(id) { setSelectedMusicFolderIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]); } async function start() { if (!channelId) return; if (!promptTitle.trim()) return; if (scriptKind === "script" && !scriptAudio && !script.trim()) return; if (narrationMode === "avatar" && heygenSubMode === "con_personaje" && scriptSource === "own" && !scriptAudio && !scriptResto.trim()) return; if (narrationMode === "avatar" && (heygenSubMode === "con_imagenes" || heygenSubMode === "split_screen") && !scriptVideo) return; if (narrationMode === "stock_clips" && stockClipsSubMode === "split_avatar" && !scriptVideo) return; setGenerating(true); setProgress(0); setStepIdx(0); setUploadPct(0); setGenError(""); const modeMap = { creation: "creacion", reference: "referencia", adaptation: "adaptacion", }; const title = promptTitle.trim(); const effectiveAssemblyMode = narrationMode === "avatar" ? "avatar_images" : narrationMode === "stock_clips" ? "stock_clips" : assemblyMode; try { let ok, d; if (scriptKind === "video" && scriptVideo && narrationMode === "stock_clips") { // "Clips de stock · Split Avatar": vídeo de avatar → XHR para rastrear progreso de subida const fd = new FormData(); fd.append("video", scriptVideo); fd.append("channel_id", channelId); fd.append("title", title); fd.append("duration_bucket", durationBucket); fd.append("transitions", transitions ? "true" : "false"); fd.append("music", musicEnabled ? "true" : "false"); fd.append("music_folder_ids", JSON.stringify(musicEnabled ? selectedMusicFolderIds : [])); fd.append("music_volume_db", String(musicVolumeDb)); fd.append("stock_use_images", stockUseImages ? "true" : "false"); const result = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/videos/generate-stock-split-avatar"); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { try { resolve({ ok: xhr.status >= 200 && xhr.status < 300, data: JSON.parse(xhr.responseText) }); } catch { reject(new Error("Respuesta inválida del servidor")); } }; xhr.onerror = () => reject(new Error("Error de red")); xhr.send(fd); }); ok = result.ok; d = result.data; setUploadPct(0); } else if (scriptKind === "video" && scriptVideo) { // "Heygen Ready": vídeo ya grabado → XHR para rastrear progreso de subida const fd = new FormData(); fd.append("video", scriptVideo); fd.append("channel_id", channelId); fd.append("title", title); fd.append("nanobanana_version", nanoBananaVersion); fd.append("duration_bucket", durationBucket); fd.append("transitions", transitions ? "true" : "false"); fd.append("music", musicEnabled ? "true" : "false"); fd.append("music_folder_ids", JSON.stringify(musicEnabled ? selectedMusicFolderIds : [])); fd.append("music_volume_db", String(musicVolumeDb)); fd.append("hook_intro_images", hookIntroImages && heygenSubMode !== "split_screen" ? "true" : "false"); fd.append("hook_intro_image_count", String(hookIntroImageCount)); fd.append("split_screen", heygenSubMode === "split_screen" ? "true" : "false"); fd.append("split_screen_use_character", useCharacterInContent ? "true" : "false"); fd.append("zoom_avatar_hook_intro_fx", heygenSubMode === "con_imagenes" && zoomAvatarHookIntroFx ? "true" : "false"); const result = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/videos/generate-heygen-ready"); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { try { resolve({ ok: xhr.status >= 200 && xhr.status < 300, data: JSON.parse(xhr.responseText) }); } catch { reject(new Error("Respuesta inválida del servidor")); } }; xhr.onerror = () => reject(new Error("Error de red")); xhr.send(fd); }); ok = result.ok; d = result.data; setUploadPct(0); } else if ((scriptKind === "script" || narrationMode === "personaje") && scriptAudio) { // Audio file → XHR para poder rastrear progreso de subida const fd = new FormData(); fd.append("audio", scriptAudio); fd.append("channel_id", channelId); fd.append("title", title); fd.append("mode", narrationMode === "personaje" ? "personaje" : "narracion"); fd.append("character_mode", narrationMode === "personaje" ? "true" : "false"); fd.append("nanobanana_version", nanoBananaVersion); fd.append("assembly_mode", effectiveAssemblyMode); fd.append("loop_images", loopImages ? "true" : "false"); fd.append("duration_bucket", durationBucket); fd.append("transitions", transitions ? "true" : "false"); fd.append("music", musicEnabled ? "true" : "false"); fd.append("music_folder_ids", JSON.stringify(musicEnabled ? selectedMusicFolderIds : [])); fd.append("music_volume_db", String(musicVolumeDb)); fd.append("voice_speed", String(voiceSpeed)); fd.append("stock_use_images", stockUseImages ? "true" : "false"); fd.append("hook_mode", hookMode || ""); fd.append("animate_first_images", narrationMode === "personaje" && animateFirstImages ? "true" : "false"); fd.append("hook_intro_images", narrationMode === "avatar" && hookIntroImages ? "true" : "false"); fd.append("hook_intro_image_count", String(hookIntroImageCount)); if (narrationMode === "avatar") fd.append("heygen_look_id", heygenLookId || (selectedChannel ? selectedChannel.heygenAvatarId : "") || ""); const result = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/videos/generate-from-audio"); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) setUploadPct(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { try { resolve({ ok: xhr.status >= 200 && xhr.status < 300, data: JSON.parse(xhr.responseText) }); } catch { reject(new Error("Respuesta inválida del servidor")); } }; xhr.onerror = () => reject(new Error("Error de red")); xhr.send(fd); }); ok = result.ok; d = result.data; setUploadPct(0); } else { const mode = scriptKind === "script" ? "narracion" : (modeMap[promptMode] || "creacion"); const transcription = scriptKind === "script" ? (narrationMode === "avatar" ? `${script}${AVATAR_INTRO_DELIMITER}${scriptResto}` : script) : (promptMode === "reference" || promptMode === "adaptation") ? refNarration : ""; const r = await fetch("/api/videos/generate", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, channel_id: channelId, mode, transcription, nanobanana_version: nanoBananaVersion, image_frequency: narrationMode === "avatar" ? imageCadence : 45, duration_bucket: durationBucket, assembly_mode: effectiveAssemblyMode, loop_images: loopImages, transitions: transitions, music: musicEnabled, music_folder_ids: musicEnabled ? selectedMusicFolderIds : [], music_volume_db: musicVolumeDb, voice_speed: voiceSpeed, stock_use_images: stockUseImages, animate_first_images: narrationMode === "personaje" && animateFirstImages, hook_intro_images: narrationMode === "avatar" && hookIntroImages, hook_intro_image_count: hookIntroImageCount, character_mode: narrationMode === "personaje", heygen_look_id: narrationMode === "avatar" ? (heygenLookId || (selectedChannel ? selectedChannel.heygenAvatarId : "") || "") : null, hook_mode: hookMode || "", }), }); ok = r.ok; d = await r.json(); } if (ok && d.success) { if (onGenerated) onGenerated(d.job_id); } else { setGenError(d.detail || "Error al iniciar la generación"); setGenerating(false); setStepIdx(-1); setUploadPct(0); } } catch { setGenError("Error de conexión con el servidor"); setGenerating(false); setStepIdx(-1); setUploadPct(0); } } return (
{/* ========= PASO 1: CANAL ========= */}
{/* ========= PASO 2: MODO DE VÍDEO (oculto en páginas dedicadas) ========= */} {!lockedMode && (
setNarrationMode("imagenes_ia")} Icon={IconSparkle} title="Imágenes IA" desc="Guion generado con IA o propio, ilustrado con imágenes."/> { setNarrationMode("personaje"); setScript(""); }} Icon={IconUser} title="Imágenes Personaje" desc="Sube tu audio y el personaje del canal aparece en las imágenes."/> setNarrationMode("stock_clips")} Icon={IconClap} title="Clips de stock" badge="BETA" desc="Montado con clips de Pexels y Pixabay, con fotos/ilustraciones opcionales."/>
)} {/* ========= Sub-selector: de dónde sale el guion (Imágenes IA / Avatar + Imágenes / Imágenes Personaje) ========= */} {(narrationMode === "imagenes_ia" || narrationMode === "avatar" || narrationMode === "personaje" || narrationMode === "stock_clips") && (
{narrationMode === "avatar" ? ( <>
setHeygenSubMode("con_personaje")} title="Nuevo Heygen con API" desc="Guion propio o con IA + intro HeyGen + imágenes del personaje."/> setHeygenSubMode("split_screen")} title="Split Screen" desc="Sube un vídeo; se divide la pantalla en dos, avatar a un lado y contenido al otro."/> setHeygenSubMode("con_imagenes")} title="Heygen con imágenes" desc="Sube un vídeo ya grabado con el avatar; insertamos imágenes intercaladas."/>
{(heygenSubMode === "con_imagenes" || heygenSubMode === "split_screen") && (
Usar personaje en el contenido
{heygenSubMode === "split_screen" ? "Las imágenes del lado de contenido muestran al personaje del canal. Si lo desactivas, son imágenes genéricas sobre el tema." : "Las imágenes intercaladas muestran al personaje del canal. Si lo desactivas, son imágenes genéricas sobre el tema."}
setUseCharacterInContent(!useCharacterInContent)}/>
)} {heygenSubMode === "con_personaje" && (
setScriptSource("ai")} title="Generar con IA" desc="Claude escribe el guion (creación, referencia o adaptación)."/> setScriptSource("own")} title="Guion propio" desc="Pega tu narración o sube el audio ya grabado."/>
)} ) : narrationMode === "stock_clips" ? ( <>
{ setStockClipsSubMode("normal"); setScriptSource("ai"); }} title="Generar con IA" desc="Claude escribe el guion (creación, referencia o adaptación)."/> { setStockClipsSubMode("normal"); setScriptSource("own"); }} title="Guion propio" desc="Pega tu narración o sube el audio ya grabado."/> setStockClipsSubMode("split_avatar")} title="Split Avatar" desc="Sube tu vídeo de avatar; se divide la pantalla 50/50 con clips de stock."/>
) : ( <>
setScriptSource("ai")} title="Generar con IA" desc="Claude escribe el guion (creación, referencia o adaptación)."/> setScriptSource("own")} title="Guion propio" desc="Pega tu narración o sube el audio ya grabado."/>
)} {narrationMode === "avatar" && heygenSubMode === "con_personaje" && selectedChannel && (!selectedChannel.avatarUrl || !selectedChannel.heygenAvatarId) && (
⚠ Al canal seleccionado le falta {!selectedChannel.avatarUrl && !selectedChannel.heygenAvatarId ? "la foto de avatar y el HeyGen Avatar ID" : !selectedChannel.avatarUrl ? "la foto de avatar" : "el HeyGen Avatar ID"}. Ve a Canales → Avatar del canal para configurarlo.
)} {narrationMode === "avatar" && (heygenSubMode === "con_imagenes" || heygenSubMode === "split_screen") && selectedChannel && !selectedChannel.avatarUrl && (
⚠ Al canal seleccionado le falta la foto de avatar (necesaria para las imágenes de cutaway). Ve a Canales → Avatar del canal para configurarlo.
)} {narrationMode === "avatar" && selectedChannel && selectedChannel.avatarUrl && (heygenSubMode !== "con_personaje" || selectedChannel.heygenAvatarId) && (
avatar
Avatar de {selectedChannel.name} listo{heygenSubMode === "con_personaje" ? " para HeyGen" : ""}.
)} {narrationMode === "personaje" && selectedChannel && !selectedChannel.avatarUrl && (
⚠ El canal seleccionado no tiene avatar configurado. Ve a Canales → Avatar del canal para añadirlo.
)} {narrationMode === "personaje" && selectedChannel && selectedChannel.avatarUrl && (
avatar
Avatar de {selectedChannel.name} listo.
)} {narrationMode === "personaje" && (
Animar las primeras 5 imágenes a vídeo
Convierte cada una de las 5 primeras imágenes generadas en un clip de vídeo animado (LTX), a partir de esa misma foto. El resto sigue siendo imágenes fijas.
setAnimateFirstImages(!animateFirstImages)}/>
)} {narrationMode === "avatar" && heygenSubMode === "con_personaje" && selectedChannel && selectedChannel.heygenGroupId && ( loadingHeygenLooks ? (
Cargando looks del avatar…
) : heygenLooks.length > 1 ? (
{heygenLooks.map((look) => { const active = (heygenLookId || selectedChannel.heygenAvatarId) === look.id; return (
setHeygenLookId(look.id)} title={look.name} style={{ cursor: "pointer", borderRadius: 8, overflow: "hidden", border: active ? "2px solid var(--accent)" : "1.5px solid var(--border-strong)", aspectRatio: "1 / 1", position: "relative", }}> {look.name}
); })}
) : null )}
)} {/* ========= Input dinámico ========= */} {(narrationMode === "imagenes_ia" || narrationMode === "avatar" || narrationMode === "personaje" || narrationMode === "stock_clips") && scriptSource === "ai" && (narrationMode !== "avatar" || heygenSubMode === "con_personaje") && !(narrationMode === "stock_clips" && stockClipsSubMode === "split_avatar") && (
{/* Sub-tipo de prompt */}
setPromptMode("creation")} title="Creación" desc="Solo hace falta el título."/> setPromptMode("reference")} title="Referencia" desc="Título + narración de referencia."/> setPromptMode("adaptation")} title="Adaptación" desc="Título + narración a adaptar."/>
setPromptTitle(e.target.value)} placeholder="Ej. Los 5 imperios antiguos más crueles de la historia"/> {(promptMode === "reference" || promptMode === "adaptation") && (