/* Tab: Video Avatar — creador de vídeo basado en "Heygen Ready · Avatar con imágenes". El usuario sube un vídeo con el avatar hablando, elige un preset de B-Roll y ajusta la configuración de montaje. */ // NOTA: los `id` (lanzamiento / basico) NO cambian — son los que viajan al backend y // definen los ciclos en _build_broll_slots. Solo cambian las etiquetas visibles. // Submodo 'Modo HeyGen': subes el vídeo del avatar ya grabado. const BROLL_OPTIONS_HEYGEN = [ { id: "lanzamiento", label: "Básico", desc: "Intro fijo (avatar 1–2s → clip 6s → imagen → avatar 4s → imagen). Luego 1 imagen cada ~22–30s hasta el min 5, y cada ~40s a partir de ahí.", }, { id: "basico", label: "Profesional", desc: "0–1min con clips + imágenes; 1–2:30 con clips puntuales; a partir de 2:30 solo imágenes intercaladas con tramos largos de avatar.", }, ]; // Submodo 'Modo Creación': sin vídeo subido — todo se genera con IA (igual que // 'Creación en lista'), eligiendo aquí el ritmo de B-Roll. Máx. 15 tramos de avatar // hablando (InfiniTalk/Kling) en todo el vídeo, en cualquiera de los 3. const BROLL_OPTIONS_CREACION = [ { id: "basico", label: "Básico", desc: "El mismo ritmo que 'Creación en lista': avatar 2s → clip 6s → avatar+split 4s → clip 6s, y luego solo imágenes con cadencia creciente (6s → 40s).", }, { id: "completo", label: "Completo", desc: "0–2min: avatar×2 + imágenes (sin clips). 2–5min: imágenes con un clip o avatar cada ~36s. 5–7min: igual cada ~50s. A partir de 7min: solo imágenes de 20s.", }, ]; const VA_STEPS_HEYGEN = [ { key: "audio", label: "Audio del vídeo", tool: "FFmpeg" }, { key: "transcribe", label: "Transcripción", tool: "Lemonfox" }, { key: "images", label: "Imágenes B-Roll", tool: "kie · GPT Image 2" }, { key: "review1", label: "Revisión de imágenes", tool: "Tú apruebas" }, { key: "clips", label: "Clips B-Roll", tool: "kie · Gemini Omni" }, { key: "review2", label: "Revisión final", tool: "Tú apruebas" }, { key: "assembly", label: "Montaje", tool: "FFmpeg" }, ]; const VA_STEPS_CREACION = [ { key: "script", label: "Guion", tool: "Claude" }, { key: "review0", label: "Revisión de guion", tool: "Tú apruebas" }, { key: "audio", label: "Voz", tool: "ai33 / Minimax" }, { key: "avatar", label: "Avatar hablando", tool: "kie · InfiniTalk/Kling" }, { key: "images", label: "Imágenes + clips B-Roll",tool: "kie · GPT Image 2 / Gemini Omni" }, { key: "review1", label: "Revisión de imágenes", tool: "Tú apruebas" }, { key: "assembly", label: "Montaje", tool: "FFmpeg" }, ]; function VideoAvatarTab({ channels, currentUser, onGenerated }) { const activeChannels = (channels || []).filter((c) => c.active !== false); const [subMode, setSubMode] = React.useState("heygen"); // "heygen" | "creacion" const [channelId, setChannelId] = React.useState(""); const [broll, setBroll] = React.useState("basico"); const [title, setTitle] = React.useState(""); const [videoFile, setVideoFile] = React.useState(null); const [narration, setNarration] = React.useState(""); const [useOwnNarration, setUseOwnNarration] = React.useState(false); const [avatarId, setAvatarId] = React.useState(""); const [channelAvatars, setChannelAvatars] = React.useState([]); // Cada submodo tiene su propio set de opciones de B-Roll — al cambiar de submodo, // vuelve al primero (id "basico" en ambos sets, aunque signifiquen cosas distintas). React.useEffect(() => { setBroll("basico"); }, [subMode]); React.useEffect(() => { setAvatarId(""); if (!channelId) { setChannelAvatars([]); return; } fetch(`/api/channels/${channelId}/avatars`, { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => setChannelAvatars(d.avatars || [])) .catch(() => setChannelAvatars([])); }, [channelId]); // Configuración del vídeo const [kenBurns, setKenBurns] = React.useState(true); // activadas por defecto const [zoomAvatarFx, setZoomAvatarFx] = React.useState(false); const [transitions, setTransitions] = React.useState(false); const [musicEnabled, setMusicEnabled] = React.useState(false); const [musicFolders, setMusicFolders] = React.useState([]); const [musicFolderIds, setMusicFolderIds] = React.useState([]); const [musicVolumeDb, setMusicVolumeDb] = React.useState(-28); const [generating, setGenerating] = React.useState(false); const [uploadPct, setUploadPct] = React.useState(0); const [genError, setGenError] = React.useState(""); const selectedChannel = activeChannels.find((c) => c.id === channelId) || null; React.useEffect(() => { fetch("/api/music/folders", { credentials: "same-origin" }) .then((r) => r.json()) .then((d) => setMusicFolders(d.folders || [])) .catch(() => setMusicFolders([])); }, []); function toggleMusicFolder(id) { setMusicFolderIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]); } const canSubmit = subMode === "heygen" ? (channelId && title.trim() && videoFile && !generating) : (channelId && title.trim() && narration.trim() && !generating); async function startHeygen() { setUploadPct(0); const fd = new FormData(); fd.append("video", videoFile); fd.append("channel_id", channelId); fd.append("title", title.trim()); fd.append("broll", broll); fd.append("duration_bucket", "regular"); fd.append("ken_burns", kenBurns ? "true" : "false"); fd.append("zoom_avatar_hook_intro_fx", zoomAvatarFx ? "true" : "false"); fd.append("transitions", transitions ? "true" : "false"); fd.append("music", musicEnabled ? "true" : "false"); fd.append("music_folder_ids", JSON.stringify(musicEnabled ? musicFolderIds : [])); fd.append("music_volume_db", String(musicVolumeDb)); fd.append("avatar_id", avatarId || ""); const result = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/videos/generate-video-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); }); setUploadPct(0); return result; } // 'Modo Creación': sin vídeo subido — mismo motor que 'Creación en lista' // (/api/videos/generate, launch_broll), pero con el preset de B-Roll elegido aquí. async function startCreacion() { const resp = await fetch("/api/videos/generate", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: title.trim(), channel_id: channelId, mode: useOwnNarration ? "narracion" : "referencia", transcription: narration, launch_broll: true, video_avatar_creacion: true, broll, voice_speed: 0.9, avatar_id: avatarId || null, }), }); const data = await resp.json().catch(() => ({})); return { ok: resp.ok, data }; } async function start() { if (!canSubmit) return; setGenerating(true); setGenError(""); try { const result = subMode === "heygen" ? await startHeygen() : await startCreacion(); if (result.ok && result.data.success) { if (onGenerated) onGenerated(result.data.job_id); } else { setGenError(result.data.detail || "Error al iniciar la generación"); setGenerating(false); } } catch (e) { setGenError(e.message || "Error de conexión con el servidor"); setGenerating(false); setUploadPct(0); } } const activeBrollOptions = subMode === "creacion" ? BROLL_OPTIONS_CREACION : BROLL_OPTIONS_HEYGEN; const brollLabel = (activeBrollOptions.find((o) => o.id === broll) || {}).label || "—"; return (
Orden de los pasos. Habrá dos paradas de revisión: primero las imágenes, luego imágenes + vídeos antes del montaje.