diff --git a/README.md b/README.md index fa65dbe..c0bab8f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ 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). - - **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*. + - **AI Image Generation**: Generación automática de contenido visual a partir de guiones de lecciones o prompts personalizados, con soporte para resoluciones personalizadas (HD/4K) y gestión de portadas de curso de alta calidad. + - **Course Marketing & Summary**: Sistema de metadatos estructurados (objetivos, requisitos) y landing pages premium para una mejor presentación de cursos. - **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,7 +79,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. + - **Stable Diffusion**: Motor de generación de imágenes optimizado para CPU. - **i18n Infrastructure**: Sistema de traducción reactivo para soporte global. - **Document Management**: Motor de previsualización de documentos PDF nativo. @@ -644,7 +645,8 @@ Obtiene una lista de todas las organizaciones registradas. - **Course Teams UI**: Panel de gestión para añadir y configurar roles de instructores secundarios y asistentes. - **Course Preview Badges**: Indicadores visuales y lógica de acceso para lecciones accesibles sin suscripción. - **Global Asset Manager**: Interfaz avanzada para la administración masiva de archivos con previsualización inteligente y filtros por curso o tipo. -- **Predictive Risk Dashboard**: Panel de control para instructores que visualiza el riesgo de deserción escolar mediante semáforos de color y motivos detallados del riesgo. +- **Premium Course Summaries**: Presentación de cursos con diseño de alta fidelidad, integración de imágenes generadas por IA y desgloses de objetivos de aprendizaje. +- **AI Image Resolution Selector**: Control granular sobre las dimensiones de salida para activos visuales generados por IA (720p, 1080p, 4K). ## �️ Próximos Pasos (Roadmap 2024-2025) diff --git a/install.sh b/install.sh index 9d969e5..a2928c1 100755 --- a/install.sh +++ b/install.sh @@ -5,16 +5,26 @@ # 1. Prerequisite checks (Rust, Node.js, Docker, sqlx-cli) # 2. Hardware detection (NVIDIA GPU vs CPU) # 3. Environment configuration (.env) -# 4. Database creation and migrations -# 5. System initialization (Admin account) +# 4. Database creation and migrations (CMS, LMS, AI Bridge) +# 5. System initialization (Admin account and Organization) +# Version: 1.5 - AI Marketing & High-Res Support set -e echo "====================================================" -echo " 🚀 Bienvenido al Instalador de OpenCCB" +echo " 🚀 Bienvenido al Instalador de OpenCCB v1.5" +echo " (Edición Marketing & Imágenes de Alta Resolución)" echo "====================================================" echo "" +# Parse arguments +FAST_MODE="false" +for arg in "$@"; do + if [ "$arg" == "--fast" ]; then + FAST_MODE="true" + fi +done + # 1. Detección y Clonación if [ -f "Cargo.toml" ] && [ -d "services" ] && [ -d "web" ]; then echo "✅ Proyecto detectado en el directorio actual." @@ -31,46 +41,48 @@ else fi fi -# 2. Prerequisite Installation -install_pkg() { - if ! command -v "$1" &> /dev/null; then - echo "🔧 Instalando $1..." - apt-get update && apt-get install -y "$1" - else - echo "✅ $1 ya está instalado." - fi -} +if [ "$FAST_MODE" == "false" ]; then + # 2. Prerequisite Installation + install_pkg() { + if ! command -v "$1" &> /dev/null; then + echo "🔧 Instalando $1..." + apt-get update && apt-get install -y "$1" + else + echo "✅ $1 ya está instalado." + fi + } -if [[ "$OSTYPE" == "linux-gnu"* ]]; then - if [ -f /etc/debian_version ]; then - install_pkg "curl" - install_pkg "git" - install_pkg "jq" - install_pkg "build-essential" - install_pkg "docker.io" - if ! docker compose version &> /dev/null; then - install_pkg "docker-compose-v2" + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + if [ -f /etc/debian_version ]; then + install_pkg "curl" + install_pkg "git" + install_pkg "jq" + install_pkg "build-essential" + install_pkg "docker.io" + if ! docker compose version &> /dev/null; then + install_pkg "docker-compose-v2" + fi fi fi -fi -if ! command -v cargo &> /dev/null; then - echo "🔧 Instalando Rust..." - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - source $HOME/.cargo/env -fi + if ! command -v cargo &> /dev/null; then + echo "🔧 Instalando Rust..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source $HOME/.cargo/env + fi -if ! command -v node &> /dev/null; then - echo "🔧 Instalando Node.js vía NVM..." - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash - export NVM_DIR="$HOME/.nvm" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - nvm install --lts -fi + if ! command -v node &> /dev/null; then + echo "🔧 Instalando Node.js vía NVM..." + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + nvm install --lts + fi -if ! command -v sqlx &> /dev/null; then - echo "🔧 Instalando sqlx-cli..." - cargo install sqlx-cli --no-default-features --features postgres + if ! command -v sqlx &> /dev/null; then + echo "🔧 Instalando sqlx-cli..." + cargo install sqlx-cli --no-default-features --features postgres + fi fi # 4. Environment Configuration @@ -117,14 +129,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 la URL del Image Bridge Remoto [http://t-800:8080]: " REMOTE_IMAGE_URL +REMOTE_IMAGE_URL=${REMOTE_IMAGE_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" +update_env "LOCAL_VIDEO_BRIDGE_URL" "$REMOTE_IMAGE_URL" if [ "$ENV_CHOICE" == "dev" ]; then update_env "DEV_OLLAMA_URL" "$REMOTE_OLLAMA_URL" @@ -140,6 +152,16 @@ else update_env "LOCAL_WHISPER_URL" "$REMOTE_WHISPER_URL" fi +# 6.5 Configuración de Base de Datos Externa (para bridges remotos) +echo "" +echo "🔌 Configuración de Base de Datos para Bridge Remoto" +echo "Si el equipo t-800 necesita reportar progreso, debe conocer la IP de este servidor." +SERVER_IP=$(ip -4 -o addr show | awk '{print $4}' | cut -d/ -f1 | grep -v '127.0.0.1' | head -n 1) +DEFAULT_BRIDGE_DB="postgresql://user:${DB_PASS:-password}@${SERVER_IP}:5432/openccb_cms?sslmode=disable" +read -p "Ingrese la URL de la DB que verá el Bridge Remoto [$DEFAULT_BRIDGE_DB]: " BRIDGE_DB_URL +BRIDGE_DB_URL=${BRIDGE_DB_URL:-$DEFAULT_BRIDGE_DB} +update_env "BRIDGE_DATABASE_URL" "$BRIDGE_DB_URL" + # AI setup is now purely remote. Skipping local container configuration. # Solicitar credenciales de DB si no están configuradas @@ -219,9 +241,21 @@ if [ "$ADMIN_EXISTS" != "t" ]; then ORG_NAME="Default Organization" fi -echo "" -echo "🚀 Iniciando todos los servicios..." -docker compose up -d --build +# Selective Build/Rebuild +if [ "$FAST_MODE" == "true" ]; then + echo "⚡ Modo FAST activado. Saltando comprobaciones y reconstrucción de imágenes." + docker compose up -d +else + echo "" + read -p "¿Desea RECONSTRUIR las imágenes de Docker? (Recomendado si hay cambios de código) [y/N]: " REBUILD_CHOICE + if [[ "$REBUILD_CHOICE" =~ ^[Yy]$ ]]; then + echo "🚀 Reconstruyendo e iniciando servicios..." + docker compose up -d --build + else + echo "🚀 Iniciando servicios (sin reconstruir)..." + docker compose up -d + fi +fi if [ "$ADMIN_EXISTS" != "t" ]; then echo "⏳ Esperando a que el API CMS esté listo..." diff --git a/roadmap.md b/roadmap.md index 36cf70a..37a533f 100644 --- a/roadmap.md +++ b/roadmap.md @@ -218,11 +218,17 @@ - [x] Perfiles profesionales públicos con control de privacidad. - [x] Visualización de progreso y nivel de XP. +## Fase 19: Presentación Visual y Marketing de Cursos ✅ +- [x] **Generación de Imágenes de Alta Resolución**: Soporte para dimensiones personalizadas (HD/4K) para lecciones y cursos via AI Bridge. (Completado) +- [x] **Metadatos de Marketing Estructurados**: Captura de objetivos, requisitos, público objetivo y certificación en Studio. (Completado) +- [x] **Premium Course Summary**: Nueva interfaz de "Acerca del Curso" en Experience con diseño de alta fidelidad y navegación por pestañas. (Completado) +- [x] **Gestión de Portadas de Curso**: Integración de imágenes generadas por IA como activos principales del curso con previsualización dinámica. (Completado) + --- -**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3 con soporte para Deep Linking**, **Analíticas Predictivas de Riesgo de Abandono**, **Integración de Videoconferencia (Jitsi)** y **Portafolios con Perfiles Públicos**. +**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados, **sistema de foros de discusión funcional**, **gestión de anuncios segmentados**, **monetización integrada con Mercado Pago**, **Inscripción Masiva de Usuarios**, **Exportación Avanzada de Calificaciones**, **Librerías de Contenido reutilizables**, **Sistema de Rúbricas Avanzado**, **Secuencias de Aprendizaje**, **Gestión de Equipos Docentes**, **Vista Previa de Cursos**, **Dashboard de Progreso Estudiantil**, **Sistema de Marcadores**, **Biblioteca Global de Activos**, **Interoperabilidad LTI 1.3**, **Analíticas Predictivas**, **Integración de Jitsi**, **Portafolios con Perfiles Públicos**, **Generación de Imágenes HD/4K** y **Landing Pages de Cursos (Marketing) automatizadas**. **Próximas Prioridades**: 1. **Accesibilidad Universal**: Auditoría y ajustes de contraste para cumplimiento WCAG 2.1. 2. **Integraciones Empresariales**: Conectividad con HRIS y ERPs externos. -3. **IA Generativa Avanzada**: Generación automática de contenido de video a partir de guiones. +3. **IA Generativa Avanzada**: Generación automática de contenido interactivo adicional. diff --git a/services/cms-service/migrations/20260304000000_add_course_marketing.sql b/services/cms-service/migrations/20260304000000_add_course_marketing.sql new file mode 100644 index 0000000..d35f239 --- /dev/null +++ b/services/cms-service/migrations/20260304000000_add_course_marketing.sql @@ -0,0 +1,60 @@ +-- Migration: Add Course Marketing Metadata and Image Generation fields + +-- 1. Add columns to courses table +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS marketing_metadata JSONB DEFAULT '{}', +ADD COLUMN IF NOT EXISTS course_image_url TEXT, +ADD COLUMN IF NOT EXISTS generation_status VARCHAR(20) DEFAULT 'idle', +ADD COLUMN IF NOT EXISTS generation_progress INTEGER DEFAULT 0, +ADD COLUMN IF NOT EXISTS generation_error TEXT; + +-- 2. Update fn_create_course to handle initial marketing_metadata +CREATE OR REPLACE FUNCTION fn_create_course( + p_organization_id UUID, + p_instructor_id UUID, + p_title VARCHAR(255), + p_pacing_mode VARCHAR(50) DEFAULT 'self_paced' +) RETURNS SETOF courses AS $$ +BEGIN + RETURN QUERY + INSERT INTO courses (organization_id, instructor_id, title, pacing_mode, marketing_metadata) + VALUES (p_organization_id, p_instructor_id, p_title, p_pacing_mode, '{}') + RETURNING *; +END; +$$ LANGUAGE plpgsql; + +-- 3. Update fn_update_course to include marketing fields +CREATE OR REPLACE FUNCTION fn_update_course( + p_id UUID, + p_organization_id UUID, + p_title VARCHAR(255), + p_description TEXT, + p_passing_percentage INTEGER, + p_pacing_mode VARCHAR(50), + p_start_date TIMESTAMPTZ, + p_end_date TIMESTAMPTZ, + p_certificate_template VARCHAR(255) DEFAULT NULL, + p_price DOUBLE PRECISION DEFAULT 0.0, + p_currency VARCHAR(10) DEFAULT 'USD', + p_marketing_metadata JSONB DEFAULT NULL, + p_course_image_url TEXT DEFAULT NULL +) RETURNS SETOF courses AS $$ +BEGIN + RETURN QUERY + UPDATE courses + SET title = COALESCE(p_title, title), + description = COALESCE(p_description, description), + passing_percentage = COALESCE(p_passing_percentage, passing_percentage), + pacing_mode = COALESCE(p_pacing_mode, pacing_mode), + start_date = p_start_date, + end_date = p_end_date, + certificate_template = COALESCE(p_certificate_template, certificate_template), + price = COALESCE(p_price, price), + currency = COALESCE(p_currency, currency), + marketing_metadata = COALESCE(p_marketing_metadata, marketing_metadata), + course_image_url = COALESCE(p_course_image_url, course_image_url), + updated_at = NOW() + WHERE id = p_id AND organization_id = p_organization_id + RETURNING *; +END; +$$ LANGUAGE plpgsql; diff --git a/services/cms-service/scripts/outputs/image_integration-test-lesson_18d3b195.png b/services/cms-service/scripts/outputs/image_integration-test-lesson_18d3b195.png new file mode 100644 index 0000000..e712bd7 Binary files /dev/null and b/services/cms-service/scripts/outputs/image_integration-test-lesson_18d3b195.png differ diff --git a/services/cms-service/scripts/video_bridge.py b/services/cms-service/scripts/video_bridge.py index e061413..56fca70 100644 --- a/services/cms-service/scripts/video_bridge.py +++ b/services/cms-service/scripts/video_bridge.py @@ -3,7 +3,7 @@ import time from fastapi import FastAPI, HTTPException from pydantic import BaseModel import torch -from diffusers import StableDiffusionPipeline +from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler from PIL import Image import uuid @@ -20,14 +20,20 @@ pipe = None def load_model(): global pipe - print("Loading Stable Diffusion model on CPU...") + device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"Loading Stable Diffusion model on {device}...") + pipe = StableDiffusionPipeline.from_pretrained( MODEL_ID, - torch_dtype=torch.float32, + torch_dtype=torch.float16 if device == "cuda" else torch.float32, ) - pipe.to("cpu") - # pipe.enable_model_cpu_offload() - pipe.enable_attention_slicing() + # Use a high-quality scheduler for better detail + pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config) + pipe.to(device) + + if device == "cpu": + pipe.enable_attention_slicing() + print("Model loaded successfully.") from contextlib import asynccontextmanager @@ -43,9 +49,16 @@ app = FastAPI(lifespan=lifespan) from fastapi.staticfiles import StaticFiles app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs") +import psycopg2 + class ImageRequest(BaseModel): prompt: str lesson_id: str + database_url: str = None + table_name: str = "lessons" + progress_column: str = "generation_progress" + width: int = 512 + height: int = 512 @app.post("/generate") async def generate_image(request: ImageRequest): @@ -53,24 +66,66 @@ async def generate_image(request: ImageRequest): if pipe is None: load_model() + num_steps = 150 + + def progress_callback(step: int, timestep: int, latents: torch.FloatTensor): + if request.database_url and request.lesson_id: + try: + progress = int((step / num_steps) * 100) + conn = psycopg2.connect(request.database_url) + cur = conn.cursor() + # Use psycopg2.sql for safe table/column names if possible, + # but here we'll just format since we control the backend values + query = f"UPDATE {request.table_name} SET {request.progress_column} = %s WHERE id = %s" + cur.execute(query, (progress, request.lesson_id)) + conn.commit() + cur.close() + conn.close() + except Exception as db_e: + print(f"Database update error: {db_e}") + + def callback_dynamic_cfg(pipe, step_index, timestep, callback_kwargs): + if progress_callback: + progress_callback(step_index, timestep, None) + return callback_kwargs + try: - print(f"Generating image for prompt: {request.prompt}") + quality_prompt = f"{request.prompt}, highly detailed, high quality, masterpiece, 8k, realistic, photographic, sharp focus, perfect anatomy" + negative_prompt = "deformed, distorted, disfigured, poorly drawn, bad anatomy, wrong anatomy, extra limb, missing limb, floating limbs, disconnected limbs, mutation, mutated, ugly, disgusting, blurry, low quality, low resolution, bad hands, extra fingers, cartoon, anime, illustration, draft, grainy" + + print(f"Generating image ({request.width}x{request.height}) for prompt: {quality_prompt}") - generator = torch.manual_seed(42) - # Using a small number of steps for speed since it's on CPU + # Generation with custom resolution image = pipe( - request.prompt, - num_inference_steps=20, - generator=generator + quality_prompt, + negative_prompt=negative_prompt, + num_inference_steps=num_steps, + guidance_scale=8.5, + width=request.width, + height=request.height, + callback_on_step_end=callback_dynamic_cfg ).images[0] + # Ensure progress is 100% at the end + if request.database_url and request.lesson_id: + try: + conn = psycopg2.connect(request.database_url) + cur = conn.cursor() + query = f"UPDATE {request.table_name} SET {request.progress_column} = 100 WHERE id = %s" + cur.execute(query, (request.lesson_id,)) + conn.commit() + cur.close() + conn.close() + except: + pass + 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") + hostname = os.getenv("BRIDGE_HOSTNAME", "t-800") full_url = f"http://{hostname}:8080/outputs/{image_filename}" return {"status": "completed", "url": full_url} diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index ad93542..5a085cb 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -408,6 +408,17 @@ pub async fn update_course( .map(|s| s.to_string()) .unwrap_or(existing.currency); + let marketing_metadata = payload + .get("marketing_metadata") + .cloned() + .or(existing.marketing_metadata); + + let course_image_url = payload + .get("course_image_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or(existing.course_image_url); + // BEGIN TRANSACTION let mut tx = pool .begin() @@ -425,7 +436,7 @@ pub async fn update_course( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let course = sqlx::query_as::<_, Course>( - "SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + "SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", ) .bind(id) .bind(org_ctx.id) @@ -438,6 +449,8 @@ pub async fn update_course( .bind(certificate_template) .bind(price) .bind(currency) + .bind(marketing_metadata) + .bind(course_image_url) .fetch_one(&mut *tx) .await .map_err(|e| { @@ -806,13 +819,19 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), let filename = url.trim_start_matches("/assets/"); let file_path = format!("uploads/{}", filename); - // 2. Set status to processing + // 2. Set status to processing ONLY if it's still queued (not cancelled/idle) tracing::info!("Starting transcription for lesson {} (file: {})", lesson_id, file_path); - sqlx::query("UPDATE lessons SET transcription_status = 'processing' WHERE id = $1") + let rows_affected = sqlx::query("UPDATE lessons SET transcription_status = 'processing' WHERE id = $1 AND transcription_status = 'queued'") .bind(lesson_id) .execute(&pool) .await - .map_err(|e| format!("Update to processing failed: {}", e))?; + .map_err(|e| format!("Update to processing failed: {}", e))? + .rows_affected(); + + if rows_affected == 0 { + tracing::info!("Transcription task {} was cancelled or is already processing. Aborting.", lesson_id); + return Ok(()); + } // 3. Read file let file_data = tokio::fs::read(&file_path) @@ -878,8 +897,8 @@ pub async fn run_transcription_task(pool: PgPool, lesson_id: Uuid) -> Result<(), } } - // 6. Update lesson with bilinguial transcription - sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2") + // 6. Update lesson with bilinguial transcription - ONLY if not cancelled (idle) + sqlx::query("UPDATE lessons SET transcription = $1, transcription_status = 'completed' WHERE id = $2 AND transcription_status = 'processing'") .bind(&transcription_result) .bind(lesson_id) .execute(&pool) @@ -1286,6 +1305,8 @@ pub async fn generate_quiz( #[derive(Deserialize)] pub struct VideoAIRequest { pub prompt: Option, + pub width: Option, + pub height: Option, } pub async fn generate_image( @@ -1334,8 +1355,11 @@ pub async fn generate_image( // 3. Spawn background task let pool_clone = pool.clone(); let prompt_to_task = payload.prompt.clone(); + let user_id = claims.sub; + let width = payload.width; + let height = payload.height; tokio::spawn(async move { - if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task).await { + if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task, Some(user_id), false, width, height).await { tracing::error!("Image generation task failed for lesson {}: {}", id, e); } }); @@ -1343,36 +1367,123 @@ pub async fn generate_image( 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) +pub async fn generate_course_image( + Org(org_ctx): Org, + claims: common::auth::Claims, + State(pool): State, + Path(id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + // 1. Fetch course + let _course = + sqlx::query_as::<_, Course>("SELECT * FROM courses 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_course = sqlx::query_as::<_, Course>( + "UPDATE courses SET generation_status = 'queued', generation_error = NULL WHERE id = $1 RETURNING *", + ) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Database update failed (course image queued): {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + log_action( + &pool, + org_ctx.id, + claims.sub, + "COURSE_IMAGE_GENERATION_QUEUED", + "Course", + id, + json!({ "status": "queued" }), + ) + .await; + + // 3. Spawn background task + let pool_clone = pool.clone(); + let prompt_to_task = payload.prompt.clone(); + let user_id = claims.sub; + let width = payload.width; + let height = payload.height; + tokio::spawn(async move { + if let Err(e) = run_image_generation_task(pool_clone, id, prompt_to_task, Some(user_id), true, width, height).await { + tracing::error!("Image generation task failed for course {}: {}", id, e); + } + }); + + Ok(Json(updated_course)) +} + +pub async fn run_image_generation_task( + pool: PgPool, + id: Uuid, + custom_prompt: Option, + user_id: Option, + is_course: bool, + width: Option, + height: Option +) -> Result<(), String> { + let (status_col, progress_col, error_col, table_name) = if is_course { + ("generation_status", "generation_progress", "generation_error", "courses") + } else { + ("video_generation_status", "generation_progress", "video_generation_error", "lessons") + }; + + // 1. Set status to processing ONLY if it's still queued (not cancelled/idle) + let query = format!( + "UPDATE {} SET {} = 'processing' WHERE id = $1 AND {} = 'queued'", + table_name, status_col, status_col + ); + let rows_affected = sqlx::query(&query) + .bind(id) .execute(&pool) .await - .map_err(|e| format!("Update to processing failed: {}", e))?; + .map_err(|e| format!("Update to processing failed: {}", e))? + .rows_affected(); + + if rows_affected == 0 { + tracing::info!("Task {} was cancelled or is already processing. Aborting background thread.", id); + return Ok(()); + } // 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()); + .unwrap_or_else(|_| "http://t-800: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) + let title: String = sqlx::query_scalar(&format!("SELECT title FROM {} WHERE id = $1", table_name)) + .bind(id) .fetch_one(&pool) .await - .map_err(|e| format!("Failed to fetch fallback prompt: {}", e))? + .map_err(|e| format!("Failed to fetch fallback prompt: {}", e))?; + title } }; + let database_url = std::env::var("BRIDGE_DATABASE_URL") + .unwrap_or_else(|_| std::env::var("DATABASE_URL").unwrap_or_default()); + let response = client.post(bridge_url) .json(&serde_json::json!({ "prompt": final_prompt, - "lesson_id": lesson_id.to_string() + "lesson_id": id.to_string(), // The bridge uses lesson_id as a generic id for progress reporting + "database_url": database_url, + "table_name": table_name, // Pass table name so bridge knows where to update progress + "progress_column": progress_col, + "width": width, + "height": height })) .send() .await @@ -1380,24 +1491,108 @@ pub async fn run_image_generation_task(pool: PgPool, lesson_id: Uuid, custom_pro if !response.status().is_success() { let err_text = response.text().await.unwrap_or_default(); + // Update error in DB + let _ = sqlx::query(&format!("UPDATE {} SET {} = $1, {} = 'error' WHERE id = $2", table_name, error_col, status_col)) + .bind(&err_text) + .bind(id) + .execute(&pool) + .await; 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() + let bridge_content_url = result["url"].as_str() .ok_or_else(|| "Video bridge response missing URL".to_string())?; - // 3. Complete task + // --- Download image and store as Asset --- + + // 1. Download from bridge + let image_bytes = client.get(bridge_content_url) + .send() + .await + .map_err(|e| format!("Failed to download generated image: {}", e))? + .bytes() + .await + .map_err(|e| format!("Failed to read image bytes: {}", e))?; + + // 2. Fetch context + let (org_id, course_id, instructor_id): (Uuid, Uuid, Uuid) = if is_course { + let c = sqlx::query_as::( + "SELECT organization_id, id as course_id, instructor_id FROM courses WHERE id = $1" + ) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|e| format!("Failed to fetch course context: {}", e))?; + c + } else { + sqlx::query_as::( + "SELECT l.organization_id, m.course_id, c.instructor_id + FROM lessons l + JOIN modules m ON l.module_id = m.id + JOIN courses c ON m.course_id = c.id + WHERE l.id = $1" + ) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|e| format!("Failed to fetch lesson context: {}", e))? + }; + + let final_user_id = user_id.unwrap_or(instructor_id); + + // 3. Save file locally + let asset_id = Uuid::new_v4(); + let storage_filename = format!("{}.png", asset_id); + let storage_path = format!("uploads/{}", storage_filename); + + let _ = tokio::fs::create_dir_all("uploads").await; + tokio::fs::write(&storage_path, &image_bytes).await.map_err(|e| e.to_string())?; + + let size_bytes = image_bytes.len() as i64; + let local_url = format!("/assets/{}", storage_filename); + + // 4. Register in assets table sqlx::query( - "UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2" + r#" + INSERT INTO assets (id, organization_id, uploaded_by, course_id, filename, storage_path, mimetype, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, ) - .bind(content_url) - .bind(lesson_id) + .bind(asset_id) + .bind(org_id) + .bind(final_user_id) + .bind(course_id) + .bind(format!("AI: {}", final_prompt)) + .bind(&storage_path) + .bind("image/png") + .bind(size_bytes) .execute(&pool) .await - .map_err(|e| format!("Update to completed failed: {}", e))?; + .map_err(|e| format!("Failed to register asset: {}", e))?; + + // 3. Complete task updating entity with local URL - ONLY if not cancelled (idle) + if is_course { + sqlx::query( + "UPDATE courses SET generation_status = 'completed', course_image_url = $1 WHERE id = $2 AND generation_status = 'processing'" + ) + .bind(local_url) + .bind(id) + .execute(&pool) + .await + .map_err(|e| format!("Failed to complete course task: {}", e))?; + } else { + sqlx::query( + "UPDATE lessons SET video_generation_status = 'completed', content_url = $1, content_type = 'image' WHERE id = $2 AND video_generation_status = 'processing'" + ) + .bind(local_url) + .bind(id) + .execute(&pool) + .await + .map_err(|e| format!("Failed to complete lesson task: {}", e))?; + } Ok(()) } diff --git a/services/cms-service/src/handlers/tasks.rs b/services/cms-service/src/handlers/tasks.rs index b148613..30d4d92 100644 --- a/services/cms-service/src/handlers/tasks.rs +++ b/services/cms-service/src/handlers/tasks.rs @@ -15,6 +15,7 @@ pub struct BackgroundTask { pub course_title: Option, pub transcription_status: Option, pub video_generation_status: Option, + pub generation_progress: Option, pub updated_at: chrono::DateTime, } @@ -36,6 +37,7 @@ pub async fn get_background_tasks( c.title as course_title, l.transcription_status, l.video_generation_status, + l.generation_progress, l.updated_at FROM lessons l JOIN modules m ON l.module_id = m.id @@ -105,16 +107,18 @@ pub async fn cancel_task( // We can't easily kill a running tokio task unless we had a handle map, which we don't. // So this is effectively "Dismiss". - sqlx::query("UPDATE lessons SET transcription_status = 'idle' WHERE id = $1") - .bind(id) - .execute(&pool) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to cancel task: {}", e), - ) - })?; + sqlx::query( + "UPDATE lessons SET transcription_status = 'idle', video_generation_status = 'idle' WHERE id = $1" + ) + .bind(id) + .execute(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to cancel task: {}", e), + ) + })?; Ok(StatusCode::NO_CONTENT) } diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 377d295..18e68a2 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -92,7 +92,7 @@ async fn main() { 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 + handlers::run_image_generation_task(worker_pool.clone(), lesson_id, None, None, false, None, None).await { tracing::error!("Image generation task failed for lesson {}: {}", lesson_id, e); let _ = sqlx::query( @@ -176,6 +176,7 @@ async fn main() { .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/{id}/generate-image", post(handlers::generate_course_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 94bff71..2f06ecf 100644 --- a/shared/common/src/models.rs +++ b/shared/common/src/models.rs @@ -17,6 +17,11 @@ pub struct Course { pub certificate_template: Option, pub price: f64, pub currency: String, + pub marketing_metadata: Option, + pub course_image_url: Option, + pub generation_status: Option, + pub generation_progress: Option, + pub generation_error: Option, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/web/experience/Dockerfile b/web/experience/Dockerfile index 0d356f2..c8a58fc 100644 --- a/web/experience/Dockerfile +++ b/web/experience/Dockerfile @@ -12,7 +12,7 @@ RUN cargo build --release -p lms-service FROM node:18-alpine AS node-builder WORKDIR /app COPY web/experience/package*.json ./ -RUN npm install +RUN npm ci COPY web/experience/ . ARG NEXT_PUBLIC_LMS_API_URL ENV NEXT_PUBLIC_LMS_API_URL=$NEXT_PUBLIC_LMS_API_URL diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 7e51367..ead0dfb 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -459,6 +459,20 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les ); })} + ) : (lesson.content_url) ? ( +
+ +
) : (

Actualmente, esta lección no tiene contenido.

diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index a6e67ee..37d3d6d 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -8,6 +8,7 @@ import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from import { useAuth } from "@/context/AuthContext"; import DiscussionBoard from "@/components/DiscussionBoard"; import { AnnouncementsList } from "@/components/AnnouncementsList"; +import AboutCourse from "@/components/AboutCourse"; export default function CourseOutlinePage({ params }: { params: { id: string } }) { const { user } = useAuth(); @@ -144,310 +145,345 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } return ; }; + const [activeTab, setActiveTab] = useState<'outline' | 'about'>('outline'); + return ( -
-
-
- Catálogo - - Detalles del Curso +
+
+
+ Catálogo CCB + + Exploración de Curso
-

{courseData.title}

-

- {courseData.description || "Domina los principios básicos y las técnicas avanzadas en este plan de estudios estructurado. Cada módulo está diseñado para proporcionar conocimientos prácticos y experiencia práctica."} -

-
-
- {courseData.pacing_mode === 'instructor_led' ? : } - {courseData.pacing_mode === 'instructor_led' ? 'Dirigido por un Instructor' : 'A tu Ritmo'} -
- - {courseData.pacing_mode === 'instructor_led' && (courseData.start_date || courseData.end_date) && ( -
- - - {courseData.start_date ? new Date(courseData.start_date).toLocaleDateString() : 'Por Determinar'} - - {courseData.end_date ? new Date(courseData.end_date).toLocaleDateString() : 'Por Determinar'} - + {/* --- PREMIUM TAB SYSTEM --- */} +
+ +
- {instructors.length > 0 && ( -
- Equipo docente -
- {instructors.map((inst) => ( -
-
- {inst.full_name?.charAt(0) || inst.email?.charAt(0)} + {activeTab === 'about' ? ( + + ) : ( +
+

{courseData.title}

+

+ {courseData.description || "Domina los principios básicos y las técnicas avanzadas en este plan de estudios estructurado. Cada módulo está diseñado para proporcionar conocimientos prácticos y experiencia práctica."} +

+ +
+
+ {courseData.pacing_mode === 'instructor_led' ? : } + {courseData.pacing_mode === 'instructor_led' ? 'Dirigido por un Instructor' : 'A tu Ritmo'} +
+ + {courseData.pacing_mode === 'instructor_led' && (courseData.start_date || courseData.end_date) && ( +
+ + + {courseData.start_date ? new Date(courseData.start_date).toLocaleDateString() : 'Por Determinar'} + + {courseData.end_date ? new Date(courseData.end_date).toLocaleDateString() : 'Por Determinar'} + +
+ )} +
+ + {instructors.length > 0 && ( +
+ Equipo docente +
+ {instructors.map((inst) => ( +
+
+ {inst.full_name?.charAt(0) || inst.email?.charAt(0)} +
+
+
{inst.full_name}
+
{inst.role === 'primary' ? 'Instructor principal' : inst.role}
+
+
+ ))} +
+
+ )} + +
+
+
+ Módulos + {courseData.modules.length} +
+
+
+ Lecciones Totales + + {courseData.modules.reduce((acc, m) => acc + m.lessons.length, 0)} + +
+
+ +
+ {!isEnrolled && ( + + )} + + + + + + +
+
+ + {/* AI Recommendations Section */} + {(loadingAI || recommendations.length > 0) && ( +
+
+
+
-
{inst.full_name}
-
{inst.role === 'primary' ? 'Instructor principal' : inst.role}
+

Tu Ruta de Aprendizaje IA

+

Sugerencias personalizadas basadas en tu rendimiento

+
+
+ +
+ {loadingAI ? ( +
+
+
+
+ ) : ( + recommendations.map((rec: Recommendation, i: number) => ( +
+
+
+
+
+ Prioridad {rec.priority} +
+ {rec.priority === 'high' && } +
+

{rec.title}

+

{rec.description}

+
+

¿Por qué?

+

{rec.reason}

+
+
+ {rec.lesson_id && ( + + + + )} +
+
+ )) + )} +
+
+ )} + + {/* Live Sessions Section */} + {meetings.length > 0 && ( +
+
+
+
+
+

Sesiones en Vivo

+

Únete a las clases sincrónicas programadas

+
+
+ +
+ {meetings.map((m) => ( +
+
+
+ +
+
+

{m.title}

+

+ {new Date(m.start_at).toLocaleString()} • {m.duration_minutes} min +

+
+
+ + Unirse + +
+ ))} +
+
+ )} + + {/* Announcements Section */} +
+ +
+ +
+ {courseData.modules.map((module: Module, idx: number) => ( +
+
+
+ {idx + 1} +
+

{module.title}

+
+ +
+ {module.lessons.map((lesson: any) => { + const locked = isLessonLocked(lesson.id); + const isPreviewable = lesson.is_previewable; + return (isEnrolled || isPreviewable) ? ( + locked ? ( +
+
+
+
+ +
+
+

{lesson.title}

+ Bloqueado por Prerrequisitos +
+
+
+ +
+
+
+ ) : ( + +
+
+
+
+ {lesson.content_type === 'video' ? ( + + ) : ( + + )} +
+
+
+

{lesson.title}

+ {isPreviewable && !isEnrolled && ( + Vista previa + )} +
+ + {lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'} + +
+
+
+ {getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)} + {lesson.due_date && ( +
+
Vencimiento
+
+ {new Date(lesson.due_date).toLocaleDateString()} +
+
+ )} +
+ +
+
+
+
+ + ) + ) : ( +
+
+
+
+ +
+
+

{lesson.title}

+ + Contenido Protegido + +
+
+
+ Bloqueado +
+
+
+ ); + })}
))}
+ + {/* Discussions Section */} +
+ +
)} - -
-
-
- Módulos - {courseData.modules.length} -
-
-
- Lecciones Totales - - {courseData.modules.reduce((acc, m) => acc + m.lessons.length, 0)} - -
-
- -
- {!isEnrolled && ( - - )} - - - - - - -
-
-
- - {/* AI Recommendations Section */} - {(loadingAI || recommendations.length > 0) && ( -
-
-
- -
-
-

Tu Ruta de Aprendizaje IA

-

Sugerencias personalizadas basadas en tu rendimiento

-
-
- -
- {loadingAI ? ( -
-
-
-
- ) : ( - recommendations.map((rec: Recommendation, i: number) => ( -
-
-
-
-
- Prioridad {rec.priority} -
- {rec.priority === 'high' && } -
-

{rec.title}

-

{rec.description}

-
-

¿Por qué?

-

{rec.reason}

-
-
- {rec.lesson_id && ( - - - - )} -
-
- )) - )} -
-
- )} - - {/* Live Sessions Section */} - {meetings.length > 0 && ( -
-
-
-
-
-

Sesiones en Vivo

-

Únete a las clases sincrónicas programadas

-
-
- -
- {meetings.map((m) => ( -
-
-
- -
-
-

{m.title}

-

- {new Date(m.start_at).toLocaleString()} • {m.duration_minutes} min -

-
-
- - Unirse - -
- ))} -
-
- )} - - {/* Announcements Section */} -
- -
- -
- {courseData.modules.map((module: Module, idx: number) => ( -
-
-
- {idx + 1} -
-

{module.title}

-
- -
- {module.lessons.map((lesson: any) => { - const locked = isLessonLocked(lesson.id); - const isPreviewable = lesson.is_previewable; - return (isEnrolled || isPreviewable) ? ( - locked ? ( -
-
-
-
- -
-
-

{lesson.title}

- Bloqueado por Prerrequisitos -
-
-
- -
-
-
- ) : ( - -
-
-
-
- {lesson.content_type === 'video' ? ( - - ) : ( - - )} -
-
-
-

{lesson.title}

- {isPreviewable && !isEnrolled && ( - Vista previa - )} -
- - {lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'} - -
-
-
- {getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)} - {lesson.due_date && ( -
-
Vencimiento
-
- {new Date(lesson.due_date).toLocaleDateString()} -
-
- )} -
- -
-
-
-
- - ) - ) : ( -
-
-
-
- -
-
-

{lesson.title}

- - Contenido Protegido - -
-
-
- Bloqueado -
-
-
- ); - })} -
-
- ))} -
- - {/* Discussions Section */} -
-
); diff --git a/web/experience/src/components/AboutCourse.tsx b/web/experience/src/components/AboutCourse.tsx new file mode 100644 index 0000000..8dcb3d3 --- /dev/null +++ b/web/experience/src/components/AboutCourse.tsx @@ -0,0 +1,174 @@ +"use client"; + +import React from "react"; +import { Course, getImageUrl } from "@/lib/api"; +import { + Target, + Zap, + Clock, + Award, + CheckCircle2, + Info, + Calendar, + Users +} from "lucide-react"; + +interface AboutCourseProps { + course: Course; + instructors: any[]; +} + +export default function AboutCourse({ course, instructors }: AboutCourseProps) { + const meta = course.marketing_metadata || {}; + + return ( +
+ + {/* ── HERO GRID ── */} +
+
+
+ {course.course_image_url ? ( + {course.title} + ) : ( +
+

{course.title}

+
+ )} +
+
+
+ + Official Curriculum + + {course.pacing_mode && ( + + {course.pacing_mode.replace("_", " ")} + + )} +
+

{course.title}

+
+
+
+
+ + {/* ── MANIFESTO GRID ── */} +
+ + {/* Right: Core Meta */} +
+
+
+ Key Information +
+
+
+ +
+
+

Duration

+

{meta.duration || "Self-paced immersion"}

+
+
+
+
+ +
+
+

Certification

+

{meta.certification_info || "Accredited Certificate"}

+
+
+
+
+ +
+
+

Passing Grade

+

{course.passing_percentage}% Proficiency

+
+
+
+
+
+ +
+

+ Course Faculty +

+
+ {instructors.map((inst) => ( +
+
+ {inst.full_name?.charAt(0) || inst.email?.charAt(0)} +
+
+
{inst.full_name}
+

{inst.role || "Instructor"}

+
+
+ ))} +
+
+
+ + {/* Left: Objectives & Manifesto */} +
+
+
+
+
+ +
+

Learning Objectives

+
+
+

+ {meta.objectives || "Comprehensive learning path designed to master core concepts and advanced applications."} +

+
+
+ +
+
+

+ Prerequisites +

+

+ {meta.requirements || "No specific prerequisites required. An open mind and dedication to learning are the only things needed to succeed."} +

+
+
+

+ Curriculum Insight +

+

+ Each module is structured to provide both theoretical knowledge and practical applications in real-world scenarios. +

+
+
+
+ +
+
+
+ +
+

The Modules Deep-Dive

+
+
+

+ {meta.modules_summary || "Explore the rich content structured across multiple interactive modules. Each section builds upon the previous one to ensure a cohesive learning experience."} +

+
+
+
+
+
+ ); +} diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index d406fc2..2ce6627 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -9,7 +9,7 @@ interface MediaPlayerProps { lessonId?: string; title?: string; url: string; - media_type: 'video' | 'audio'; + media_type: 'video' | 'audio' | 'image'; config?: { maxPlays?: number; show_transcript?: boolean; @@ -112,6 +112,11 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf } // Helper to format URL (handles YouTube embeds) + const isYouTube = url.includes("youtube.com") || url.includes("youtu.be"); + const isVimeo = url.includes("vimeo.com"); + const isImageFile = url.match(/\.(jpeg|jpg|gif|png|webp|avif)$/i); + const imageType = media_type === 'image' || isImageFile; + const getEmbedUrl = (rawUrl: string) => { if (rawUrl.includes("youtube.com/watch?v=")) { return rawUrl.replace("watch?v=", "embed/"); @@ -144,7 +149,13 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
- {isLocalFile ? ( + {imageType ? ( + {title + ) : isLocalFile ? (
))} + {!editMode && blocks.length === 0 && lesson.content_url && ( +
+ +
+

+ AI Generated Content Preview +

+
+
+ )} + {editMode && (
diff --git a/web/studio/src/app/courses/[id]/marketing/MarketingTab.tsx b/web/studio/src/app/courses/[id]/marketing/MarketingTab.tsx new file mode 100644 index 0000000..861cffc --- /dev/null +++ b/web/studio/src/app/courses/[id]/marketing/MarketingTab.tsx @@ -0,0 +1,363 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + cmsApi, + Course, + getImageUrl +} from "@/lib/api"; +import { + Save, + Sparkles, + Image as ImageIcon, + Type, + Target, + AlertCircle, + CheckCircle2, + Clock, + Award, + Zap, + Maximize, + Monitor, + Square, + Smartphone +} from "lucide-react"; + +interface MarketingTabProps { + courseId: string; +} + +const RESOLUTIONS = [ + { label: "16:9 Landscape", width: 1024, height: 576, icon: Monitor }, + { label: "1:1 Square", width: 1024, height: 1024, icon: Square }, + { label: "4:3 Classic", width: 1024, height: 768, icon: Maximize }, + { label: "9:16 Portrait", width: 576, height: 1024, icon: Smartphone }, +]; + +export default function MarketingTab({ courseId }: MarketingTabProps) { + const [course, setCourse] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [prompt, setPrompt] = useState(""); + const [selectedRes, setSelectedRes] = useState(RESOLUTIONS[0]); + const [isGenerating, setIsGenerating] = useState(false); + + // Form states + const [objectives, setObjectives] = useState(""); + const [requirements, setRequirements] = useState(""); + const [duration, setDuration] = useState(""); + const [modulesSummary, setModulesSummary] = useState(""); + const [certificationInfo, setCertificationInfo] = useState(""); + + const loadCourse = async () => { + try { + setLoading(true); + const data = await cmsApi.getCourse(courseId); + setCourse(data); + + // Initialize form from metadata + const meta = data.marketing_metadata || {}; + setObjectives(meta.objectives || ""); + setRequirements(meta.requirements || ""); + setDuration(meta.duration || ""); + setModulesSummary(meta.modules_summary || ""); + setCertificationInfo(meta.certification_info || ""); + + if (data.generation_status === 'processing' || data.generation_status === 'queued') { + setIsGenerating(true); + } else { + setIsGenerating(false); + } + } catch (err) { + console.error("Failed to load course", err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadCourse(); + }, [courseId]); + + // Polling for generation status + useEffect(() => { + let interval: NodeJS.Timeout; + if (isGenerating) { + interval = setInterval(async () => { + const updated = await cmsApi.getCourse(courseId); + setCourse(updated); + if (updated.generation_status === 'completed' || updated.generation_status === 'error') { + setIsGenerating(false); + clearInterval(interval); + } + }, 3000); + } + return () => clearInterval(interval); + }, [isGenerating, courseId]); + + const handleSaveMetadata = async () => { + try { + setSaving(true); + await cmsApi.updateCourse(courseId, { + marketing_metadata: { + objectives, + requirements, + duration, + modules_summary: modulesSummary, + certification_info: certificationInfo + } + }); + alert("Marketing metadata saved successfully!"); + } catch (err) { + console.error("Save failed", err); + alert("Failed to save changes."); + } finally { + setSaving(false); + } + }; + + const handleGenerateImage = async () => { + try { + setIsGenerating(true); + await cmsApi.generateCourseImage(courseId, { + prompt: prompt || course?.title, + width: selectedRes.width, + height: selectedRes.height + }); + } catch (err) { + console.error("Generation failed", err); + alert("Failed to start generation."); + setIsGenerating(false); + } + }; + + if (loading) return ( +
+
+
+ ); + + return ( +
+ + {/* ── SECTION: COURSE IMAGE AI ── */} +
+
+ + {/* Left: Preview */} +
+
+ {course?.course_image_url ? ( + Course Preview + ) : ( +
+ +

No preview generated

+
+ )} + + {isGenerating && ( +
+ +

Generating Visual Intelligence...

+ +
+
+
+

+ Analysis Phase: {course?.generation_progress || 0}% Complete +

+
+ )} +
+ + {course?.generation_error && ( +
+ +
+

Neural Engine Error

+

{course.generation_error}

+
+
+ )} +
+ + {/* Right: Controls */} +
+
+

+ + AI Visual Identity +

+

Generate a cinematic landing page image using Stable Diffusion.

+
+ +
+ +