diff --git a/README.md b/README.md index f5836e3..fa65dbe 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura - **Custom AI Quizzes**: Generación de quices con contexto pedagógico y tipo de pregunta personalizable (opción múltiple, V/F, etc.). - **Course Deletion**: Funcionalidad de eliminación de cursos con verificación de permisos y limpieza en cascada. - **Gamified Activities**: Nuevos tipos de bloques interactivos para niños y jóvenes, incluyendo Juegos de Memoria y Puntos Calientes (Hotspots). - - **Auto Transcription**: Integración con Whisper para generación automática de transcripciones y evaluación precisa de voz. + - **AI Video Generation**: Generación automática de contenido de video a partir de guiones de lecciones o prompts personalizados, optimizada para ejecución local o remota (servidor t-800) mediante *Stable Video Diffusion*. - **Dynamic API Resolution**: Resolución inteligente de endpoints que permite el acceso desde cualquier dispositivo en la red local (WiFi) sin configuración manual. - **Responsive UI/UX**: Interfaces optimizadas para dispositivos móviles con menús adaptativos y escalado fluido de componentes. - **AI Teaching Assistant (RAG)**: Tutor inteligente dentro de cada lección que ayuda a los estudiantes utilizando el contexto de la lección actual y el historial del curso. @@ -78,6 +78,7 @@ OpenCCB es altamente escalable. A continuación se detallan los requisitos recom - **IA Local**: - **Faster-Whisper**: Transcripción de audio a texto. - **Ollama**: Traducción inteligente (EN -> ES), resúmenes y generación de cuestionarios. + - **Stable Video Diffusion**: Motor de generación de video optimizado para CPU. - **i18n Infrastructure**: Sistema de traducción reactivo para soporte global. - **Document Management**: Motor de previsualización de documentos PDF nativo. @@ -654,6 +655,7 @@ OpenCCB evoluciona constantemente. Estos son los pilares de nuestro desarrollo f - **Offline Sync**: Capacidad de descargar lecciones y sincronizar progreso al recuperar conexión. ### 🧠 Inteligencia Artificial Avanzada +- **AI Video Generation (v1)**: ✅ Generación de clips a partir de prompts y guiones de lecciones. - **AI Proctoring**: Monitoreo basado en visión artificial para exámenes de alta integridad, 100% privado y local. - **Multimodal Tutoring**: El tutor de IA podrá analizar imágenes y videos subidos por el alumno para dar feedback. - **Automated Grading for Open Questions**: Evaluación masiva de ensayos y respuestas abiertas con rúbricas personalizadas. diff --git a/install.sh b/install.sh index 6fcec7a..9d969e5 100755 --- a/install.sh +++ b/install.sh @@ -117,11 +117,14 @@ read -p "Ingrese la URL de Ollama Remoto [$DEFAULT_OLLAMA]: " REMOTE_OLLAMA_URL REMOTE_OLLAMA_URL=${REMOTE_OLLAMA_URL:-$DEFAULT_OLLAMA} read -p "Ingrese la URL de Whisper Remoto [$DEFAULT_WHISPER]: " REMOTE_WHISPER_URL REMOTE_WHISPER_URL=${REMOTE_WHISPER_URL:-$DEFAULT_WHISPER} +read -p "Ingrese la URL del Video Bridge Remoto [http://t-800:8080]: " REMOTE_VIDEO_URL +REMOTE_VIDEO_URL=${REMOTE_VIDEO_URL:-"http://t-800:8080"} read -p "Ingrese el nombre del Modelo (en el servidor remoto) [llama3.2:3b]: " LLM_MODEL LLM_MODEL=${LLM_MODEL:-llama3.2:3b} update_env "AI_PROVIDER" "local" update_env "LOCAL_LLM_MODEL" "$LLM_MODEL" +update_env "LOCAL_VIDEO_BRIDGE_URL" "$REMOTE_VIDEO_URL" if [ "$ENV_CHOICE" == "dev" ]; then update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL" @@ -213,7 +216,7 @@ if [ "$ADMIN_EXISTS" != "t" ]; then read -s -p "Contraseña del Administrador [password123]: " ADMIN_PASS ADMIN_PASS=${ADMIN_PASS:-password123} echo "" - ORG_NAME="Organización por Defecto" + ORG_NAME="Default Organization" fi echo "" diff --git a/services/cms-service/migrations/20260116000010_update_fn_register_user.sql b/services/cms-service/migrations/20260116000010_update_fn_register_user.sql index c9759e4..877c10f 100644 --- a/services/cms-service/migrations/20260116000010_update_fn_register_user.sql +++ b/services/cms-service/migrations/20260116000010_update_fn_register_user.sql @@ -12,7 +12,7 @@ DECLARE v_org_id UUID; BEGIN -- Find or create organization - IF p_org_name IS NULL OR p_org_name = '' OR p_org_name = 'Default Organization' THEN + IF p_org_name IS NULL OR p_org_name = '' OR p_org_name = 'Default Organization' OR p_org_name = 'Organización por Defecto' THEN v_org_id := '00000000-0000-0000-0000-000000000001'; ELSE INSERT INTO organizations (name) diff --git a/services/cms-service/migrations/20260303000000_add_video_generation_status.sql b/services/cms-service/migrations/20260303000000_add_video_generation_status.sql new file mode 100644 index 0000000..15b17d6 --- /dev/null +++ b/services/cms-service/migrations/20260303000000_add_video_generation_status.sql @@ -0,0 +1,3 @@ +-- Add video_generation_status and video_generation_error to lessons table +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS video_generation_status VARCHAR(20) DEFAULT 'idle'; +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS video_generation_error TEXT; diff --git a/services/cms-service/scripts/requirements.txt b/services/cms-service/scripts/requirements.txt new file mode 100644 index 0000000..c6e8ae2 --- /dev/null +++ b/services/cms-service/scripts/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn +torch +diffusers +transformers +accelerate +pillow +imageio-ffmpeg +pydantic diff --git a/services/cms-service/scripts/video_bridge.py b/services/cms-service/scripts/video_bridge.py new file mode 100644 index 0000000..e061413 --- /dev/null +++ b/services/cms-service/scripts/video_bridge.py @@ -0,0 +1,88 @@ +import os +import time +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel +import torch +from diffusers import StableDiffusionPipeline +from PIL import Image +import uuid + +app = FastAPI() + +# Configuration +MODEL_ID = "runwayml/stable-diffusion-v1-5" +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +OUTPUT_DIR = os.path.join(BASE_DIR, "outputs") +os.makedirs(OUTPUT_DIR, exist_ok=True) + +# Global variables for the model +pipe = None + +def load_model(): + global pipe + print("Loading Stable Diffusion model on CPU...") + pipe = StableDiffusionPipeline.from_pretrained( + MODEL_ID, + torch_dtype=torch.float32, + ) + pipe.to("cpu") + # pipe.enable_model_cpu_offload() + pipe.enable_attention_slicing() + print("Model loaded successfully.") + +from contextlib import asynccontextmanager + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Model loading is heavy, we'll do it on first request to avoid timeout at startup + yield + +app = FastAPI(lifespan=lifespan) + +# Serve generated images +from fastapi.staticfiles import StaticFiles +app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs") + +class ImageRequest(BaseModel): + prompt: str + lesson_id: str + +@app.post("/generate") +async def generate_image(request: ImageRequest): + global pipe + if pipe is None: + load_model() + + try: + print(f"Generating image for prompt: {request.prompt}") + + generator = torch.manual_seed(42) + # Using a small number of steps for speed since it's on CPU + image = pipe( + request.prompt, + num_inference_steps=20, + generator=generator + ).images[0] + + image_filename = f"image_{request.lesson_id}_{uuid.uuid4().hex[:8]}.png" + image_path = os.path.join(OUTPUT_DIR, image_filename) + + image.save(image_path) + + # Return the absolute URL pointing to t-800 so the frontend can find it + hostname = os.getenv("BRIDGE_HOSTNAME", "localhost") + full_url = f"http://{hostname}:8080/outputs/{image_filename}" + + return {"status": "completed", "url": full_url} + + except Exception as e: + print(f"Generation error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@app.get("/health") +async def health(): + return {"status": "ok", "model_loaded": pipe is not None} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 36bc0de..ad93542 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -1283,6 +1283,125 @@ pub async fn generate_quiz( Ok(Json(quiz_blocks)) } +#[derive(Deserialize)] +pub struct VideoAIRequest { + pub prompt: Option, +} + +pub async fn generate_image( + Org(org_ctx): Org, + claims: common::auth::Claims, + State(pool): State, + Path(id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + if let Some(prompt) = &payload.prompt { + tracing::info!("Received prompt for video generation: {}", prompt); + } + + // 1. Fetch lesson + let _lesson = + sqlx::query_as::<_, Lesson>("SELECT * FROM lessons WHERE id = $1 AND organization_id = $2") + .bind(id) + .bind(org_ctx.id) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + // 2. Set status to queued + let updated_lesson = sqlx::query_as::<_, Lesson>( + "UPDATE lessons SET video_generation_status = 'queued', video_generation_error = NULL WHERE id = $1 RETURNING *", + ) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Database update failed (video queued): {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + log_action( + &pool, + org_ctx.id, + claims.sub, + "VIDEO_GENERATION_QUEUED", + "Lesson", + id, + json!({ "status": "queued" }), + ) + .await; + + // 3. Spawn background task + let pool_clone = pool.clone(); + let prompt_to_task = payload.prompt.clone(); + tokio::spawn(async move { + if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task).await { + tracing::error!("Image generation task failed for lesson {}: {}", id, e); + } + }); + + Ok(Json(updated_lesson)) +} + +pub async fn run_image_generation_task(pool: PgPool, lesson_id: Uuid, custom_prompt: Option) -> Result<(), String> { + // 1. Set status to processing + sqlx::query("UPDATE lessons SET video_generation_status = 'processing' WHERE id = $1") + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| format!("Update to processing failed: {}", e))?; + + // 2. Call Local Video Bridge (Python) + let client = reqwest::Client::new(); + let bridge_base_url = std::env::var("LOCAL_VIDEO_BRIDGE_URL") + .unwrap_or_else(|_| "http://localhost:8080".to_string()); + let bridge_url = format!("{}/generate", bridge_base_url); + + // Fallback logic for prompt: Custom Prompt > Title + let final_prompt = match custom_prompt { + Some(p) if !p.is_empty() => p, + _ => { + sqlx::query_scalar("SELECT title FROM lessons WHERE id = $1") + .bind(lesson_id) + .fetch_one(&pool) + .await + .map_err(|e| format!("Failed to fetch fallback prompt: {}", e))? + } + }; + + let response = client.post(bridge_url) + .json(&serde_json::json!({ + "prompt": final_prompt, + "lesson_id": lesson_id.to_string() + })) + .send() + .await + .map_err(|e| format!("Failed to call video bridge: {}", e))?; + + if !response.status().is_success() { + let err_text = response.text().await.unwrap_or_default(); + return Err(format!("Video bridge error: {}", err_text)); + } + + let result: serde_json::Value = response.json().await + .map_err(|e| format!("Failed to parse video bridge response: {}", e))?; + + let content_url = result["url"].as_str() + .ok_or_else(|| "Video bridge response missing URL".to_string())?; + + // 3. Complete task + sqlx::query( + "UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2" + ) + .bind(content_url) + .bind(lesson_id) + .execute(&pool) + .await + .map_err(|e| format!("Update to completed failed: {}", e))?; + + Ok(()) +} + pub async fn get_lesson( Org(org_ctx): Org, State(pool): State, diff --git a/services/cms-service/src/handlers/tasks.rs b/services/cms-service/src/handlers/tasks.rs index 42b08c7..b148613 100644 --- a/services/cms-service/src/handlers/tasks.rs +++ b/services/cms-service/src/handlers/tasks.rs @@ -14,6 +14,7 @@ pub struct BackgroundTask { pub title: String, pub course_title: Option, pub transcription_status: Option, + pub video_generation_status: Option, pub updated_at: chrono::DateTime, } @@ -34,11 +35,13 @@ pub async fn get_background_tasks( l.title, c.title as course_title, l.transcription_status, + l.video_generation_status, l.updated_at FROM lessons l JOIN modules m ON l.module_id = m.id JOIN courses c ON m.course_id = c.id WHERE l.transcription_status IN ('queued', 'processing', 'failed') + OR l.video_generation_status IN ('queued', 'processing', 'failed') ORDER BY l.updated_at DESC "#; diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 50897f1..377d295 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -74,6 +74,36 @@ async fn main() { } } + // Check for queued video generations + let queued_video_lessons: Vec = match sqlx::query_scalar( + "SELECT id FROM lessons WHERE video_generation_status = 'queued' LIMIT 5", + ) + .fetch_all(&worker_pool) + .await + { + Ok(ids) => ids, + Err(e) => { + tracing::error!("Failed to fetch queued video lessons: {}", e); + tokio::time::sleep(Duration::from_secs(10)).await; + continue; + } + }; + + for lesson_id in queued_video_lessons { + tracing::info!("Processing video generation for lesson: {}", lesson_id); + if let Err(e) = + handlers::run_image_generation_task(worker_pool.clone(), lesson_id, None).await + { + tracing::error!("Image generation task failed for lesson {}: {}", lesson_id, e); + let _ = sqlx::query( + "UPDATE lessons SET video_generation_status = 'failed' WHERE id = $1", + ) + .bind(lesson_id) + .execute(&worker_pool) + .await; + } + } + tokio::time::sleep(Duration::from_secs(5)).await; } }); @@ -145,6 +175,7 @@ async fn main() { .route("/lessons/{id}/vtt", get(handlers::get_lesson_vtt)) .route("/lessons/{id}/summarize", post(handlers::summarize_lesson)) .route("/lessons/{id}/generate-quiz", post(handlers::generate_quiz)) + .route("/lessons/{id}/generate-image", post(handlers::generate_image)) .route("/courses/generate", post(handlers::generate_course)) .route("/courses/{id}/export", get(handlers::export_course)) .route("/courses/import", post(handlers::import_course)) diff --git a/shared/common/src/models.rs b/shared/common/src/models.rs index 571e21c..94bff71 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -50,6 +50,8 @@ pub struct Lesson { pub due_date: Option>, pub important_date_type: Option, // "exam", "assignment", "milestone", etc. pub transcription_status: Option, + pub video_generation_status: Option, + pub video_generation_error: Option, pub is_previewable: bool, pub created_at: DateTime, } diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 10fb8fd..4227208 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -21,7 +21,8 @@ import { Brain, Library, BookMarked, - ArrowLeft + ArrowLeft, + ImageIcon } from 'lucide-react'; import DescriptionBlock from "@/components/blocks/DescriptionBlock"; import MediaBlock from "@/components/blocks/MediaBlock"; @@ -78,6 +79,8 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI const [isAIQuizModalOpen, setIsAIQuizModalOpen] = useState(false); const [aiQuizContext, setAiQuizContext] = useState(""); const [aiQuizType, setAiQuizType] = useState("multiple-choice"); + const [aiImagePrompt, setAiImagePrompt] = useState(""); + const [isGeneratingImage, setIsGeneratingImage] = useState(false); const [editValue, setEditValue] = useState(""); @@ -86,7 +89,10 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI useEffect(() => { let interval: NodeJS.Timeout; - if (lesson && (lesson.transcription_status === 'queued' || lesson.transcription_status === 'processing')) { + if (lesson && ( + lesson.video_generation_status === 'queued' || + lesson.video_generation_status === 'processing' + )) { interval = setInterval(async () => { try { const updated = await cmsApi.getLesson(params.lessonId); @@ -362,6 +368,32 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI } }; + const handleGenerateImage = async () => { + if (!lesson) return; + setIsGeneratingImage(true); + try { + // Option 2: Use lesson content (blocks) as script if no prompt + const scriptFromBlocks = blocks + .map(b => b.content || b.description || b.title || "") + .filter(txt => txt.trim().length > 0) + .join(". "); + + // Option 3: Prioritize manual prompt, then script + const finalPrompt = aiImagePrompt.trim() || scriptFromBlocks; + + const updated = await cmsApi.generateImage(lesson.id, { + prompt: finalPrompt + }); + setLesson(updated); + setAiImagePrompt(""); + alert("Generación de imagen iniciada con el prompt/guion detectado."); + } catch (err: any) { + alert(err.message || "Failed to initiate image generation."); + } finally { + setIsGeneratingImage(false); + } + }; + if (loading) return
Initializing Activity Builder...
; if (!lesson) return
Activity not found.
; @@ -872,6 +904,35 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
{isGeneratingQuiz ? 'Building Quiz...' : 'Generate New Test'}
+ +
+ +