Aplicación PHP + MySQL para guardar transcripciones de Whisper

 

Aplicación PHP + MySQL para guardar transcripciones de Whisper

¡Excelente idea! Te voy a mostrar cómo crear una aplicación web completa para gestionar tus transcripciones de Whisper. Podrás subir videos, transcribirlos automáticamente (usando Whisper en el servidor) y guardar todo en una base de datos MySQL.


📁 Estructura del Proyecto

text
whisper-app/
├── config/
│   └── database.php
├── includes/
│   ├── header.php
│   └── footer.php
├── assets/
│   ├── css/
│   │   └── style.css
│   └── uploads/
├── index.php
├── upload.php
├── transcribe.php
├── view.php
├── edit.php
├── delete.php
└── export.php

🗄️ Configuración de la Base de Datos

1. Crear la base de datos y la tabla

Ejecuta este SQL en phpMyAdmin o en tu cliente MySQL:

sql
CREATE DATABASE whisper_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE whisper_app;

CREATE TABLE transcriptions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    video_name VARCHAR(255) NOT NULL,
    video_path VARCHAR(500) NOT NULL,
    text TEXT NOT NULL,
    srt_subtitles TEXT,
    word_count INT DEFAULT 0,
    language VARCHAR(50) DEFAULT 'Spanish',
    model_used VARCHAR(50) DEFAULT 'base',
    duration INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

🔧 Archivos de Configuración

2. config/database.php

php
<?php
// Configuración de la base de datos
define('DB_HOST', 'localhost');
define('DB_NAME', 'whisper_app');
define('DB_USER', 'root');
define('DB_PASS', '');

// Configuración de la aplicación
define('APP_NAME', 'Whisper Transcriber');
define('UPLOAD_DIR', __DIR__ . '/../assets/uploads/');
define('MAX_FILE_SIZE', 500 * 1024 * 1024); // 500 MB
define('ALLOWED_EXTENSIONS', ['mp4', 'mkv', 'avi', 'mov', 'mp3', 'wav', 'm4a']);

// Intentar conectar a la base de datos
try {
    $pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch(PDOException $e) {
    die("Error de conexión: " . $e->getMessage());
}

// Crear directorio de uploads si no existe
if (!is_dir(UPLOAD_DIR)) {
    mkdir(UPLOAD_DIR, 0777, true);
}
?>

🎨 Archivos de Plantilla

3. includes/header.php

php
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?php echo APP_NAME; ?></title>
    <link rel="stylesheet" href="assets/css/style.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
    <nav class="navbar">
        <div class="container">
            <a href="index.php" class="navbar-brand">
                <i class="fas fa-microphone"></i> <?php echo APP_NAME; ?>
            </a>
            <div class="navbar-nav">
                <a href="index.php" class="nav-link"><i class="fas fa-home"></i> Inicio</a>
                <a href="upload.php" class="nav-link"><i class="fas fa-upload"></i> Subir Video</a>
            </div>
        </div>
    </nav>
    <main class="container">

4. includes/footer.php

php
    </main>
    <footer class="footer">
        <div class="container">
            <p>&copy; <?php echo date('Y'); ?> <?php echo APP_NAME; ?> - Hecho con ❤️ usando Whisper</p>
        </div>
    </footer>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
    <script src="assets/js/main.js"></script>
</body>
</html>

🎯 Página Principal

5. index.php

php
<?php
require_once 'config/database.php';
include 'includes/header.php';

// Obtener todas las transcripciones
$query = "SELECT * FROM transcriptions ORDER BY created_at DESC";
$stmt = $pdo->query($query);
$transcriptions = $stmt->fetchAll();
?>

<div class="row">
    <div class="col-12">
        <div class="card">
            <div class="card-header">
                <h2><i class="fas fa-list"></i> Transcripciones Guardadas</h2>
                <a href="upload.php" class="btn btn-primary">
                    <i class="fas fa-plus"></i> Nueva Transcripción
                </a>
            </div>
            
            <?php if (empty($transcriptions)): ?>
                <div class="empty-state">
                    <i class="fas fa-inbox" style="font-size: 48px;"></i>
                    <h3>No hay transcripciones</h3>
                    <p>Sube tu primer video para comenzar</p>
                    <a href="upload.php" class="btn btn-primary">Subir Video</a>
                </div>
            <?php else: ?>
                <div class="table-responsive">
                    <table class="table">
                        <thead>
                            <tr>
                                <th>ID</th>
                                <th>Video</th>
                                <th>Palabras</th>
                                <th>Idioma</th>
                                <th>Fecha</th>
                                <th>Acciones</th>
                            </tr>
                        </thead>
                        <tbody>
                            <?php foreach ($transcriptions as $item): ?>
                            <tr>
                                <td>#<?php echo $item['id']; ?></td>
                                <td>
                                    <strong><?php echo htmlspecialchars($item['video_name']); ?></strong><br>
                                    <small class="text-muted">Modelo: <?php echo $item['model_used']; ?></small>
                                </td>
                                <td><?php echo number_format($item['word_count']); ?></td>
                                <td><span class="badge"><?php echo $item['language']; ?></span></td>
                                <td><?php echo date('d/m/Y H:i', strtotime($item['created_at'])); ?></td>
                                <td>
                                    <div class="btn-group">
                                        <a href="view.php?id=<?php echo $item['id']; ?>" class="btn btn-sm btn-info">
                                            <i class="fas fa-eye"></i>
                                        </a>
                                        <a href="edit.php?id=<?php echo $item['id']; ?>" class="btn btn-sm btn-warning">
                                            <i class="fas fa-edit"></i>
                                        </a>
                                        <a href="export.php?id=<?php echo $item['id']; ?>&format=txt" class="btn btn-sm btn-success">
                                            <i class="fas fa-download"></i>
                                        </a>
                                        <a href="delete.php?id=<?php echo $item['id']; ?>" class="btn btn-sm btn-danger" onclick="return confirm('¿Estás seguro de eliminar esta transcripción?')">
                                            <i class="fas fa-trash"></i>
                                        </a>
                                    </div>
                                </td>
                            </tr>
                            <?php endforeach; ?>
                        </tbody>
                    </table>
                </div>
            <?php endif; ?>
        </div>
    </div>
</div>

<?php include 'includes/footer.php'; ?>

📤 Subir y Transcribir

6. upload.php

php
<?php
require_once 'config/database.php';
include 'includes/header.php';

$message = '';
$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['video'])) {
    $file = $_FILES['video'];
    $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
    
    // Validar extensión
    if (!in_array($extension, ALLOWED_EXTENSIONS)) {
        $error = "Formato de archivo no permitido. Extensiones permitidas: " . implode(', ', ALLOWED_EXTENSIONS);
    }
    // Validar tamaño
    elseif ($file['size'] > MAX_FILE_SIZE) {
        $error = "El archivo excede el tamaño máximo de " . (MAX_FILE_SIZE / 1024 / 1024) . " MB";
    }
    // Validar errores de subida
    elseif ($file['error'] !== UPLOAD_ERR_OK) {
        $error = "Error al subir el archivo: " . $file['error'];
    } else {
        // Generar nombre único
        $filename = uniqid() . '.' . $extension;
        $filepath = UPLOAD_DIR . $filename;
        
        if (move_uploaded_file($file['tmp_name'], $filepath)) {
            // Redirigir a transcripción
            header("Location: transcribe.php?file=" . urlencode($filename) . "&original_name=" . urlencode($file['name']));
            exit;
        } else {
            $error = "Error al mover el archivo al servidor";
        }
    }
}
?>

<div class="row">
    <div class="col-md-8 offset-md-2">
        <div class="card">
            <div class="card-header">
                <h2><i class="fas fa-upload"></i> Subir Video/Audio</h2>
            </div>
            <div class="card-body">
                <?php if ($error): ?>
                    <div class="alert alert-danger"><?php echo $error; ?></div>
                <?php endif; ?>
                
                <?php if ($message): ?>
                    <div class="alert alert-success"><?php echo $message; ?></div>
                <?php endif; ?>
                
                <form action="" method="POST" enctype="multipart/form-data" id="uploadForm">
                    <div class="form-group">
                        <label for="video" class="drop-zone">
                            <i class="fas fa-cloud-upload-alt" style="font-size: 48px;"></i>
                            <h3>Arrastra tu archivo aquí</h3>
                            <p>o haz clic para seleccionar</p>
                            <small>Formatos: <?php echo implode(', ', ALLOWED_EXTENSIONS); ?> | Máx: <?php echo MAX_FILE_SIZE / 1024 / 1024; ?> MB</small>
                            <input type="file" name="video" id="video" accept="<?php echo '.' . implode(',.', ALLOWED_EXTENSIONS); ?>" required>
                        </label>
                    </div>
                    
                    <div class="form-group" id="fileInfo" style="display:none;">
                        <div class="file-info">
                            <i class="fas fa-file-video"></i>
                            <span id="fileName"></span>
                            <span id="fileSize"></span>
                        </div>
                    </div>
                    
                    <button type="submit" class="btn btn-primary btn-block">
                        <i class="fas fa-upload"></i> Subir y Transcribir
                    </button>
                </form>
            </div>
        </div>
        
        <div class="card mt-4">
            <div class="card-body">
                <h5><i class="fas fa-info-circle"></i> Consejos</h5>
                <ul>
                    <li>Whisper funciona mejor con audio en español</li>
                    <li>Para mejores resultados, usa archivos con audio claro</li>
                    <li>La transcripción puede tomar varios minutos</li>
                </ul>
            </div>
        </div>
    </div>
</div>

<script>
document.getElementById('video').addEventListener('change', function(e) {
    const file = this.files[0];
    if (file) {
        document.getElementById('fileInfo').style.display = 'block';
        document.getElementById('fileName').textContent = file.name;
        document.getElementById('fileSize').textContent = (file.size / 1024 / 1024).toFixed(2) + ' MB';
    }
});

// Drag and drop
const dropZone = document.querySelector('.drop-zone');
dropZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    dropZone.style.borderColor = '#007bff';
});
dropZone.addEventListener('dragleave', () => {
    dropZone.style.borderColor = '#ddd';
});
dropZone.addEventListener('drop', (e) => {
    e.preventDefault();
    dropZone.style.borderColor = '#ddd';
    const files = e.dataTransfer.files;
    if (files.length > 0) {
        document.getElementById('video').files = files;
        document.getElementById('video').dispatchEvent(new Event('change'));
    }
});
</script>

<?php include 'includes/footer.php'; ?>

🤖 Procesar Transcripción con Whisper

7. transcribe.php

php
<?php
require_once 'config/database.php';

// Verificar que se recibió el archivo
if (!isset($_GET['file']) || !isset($_GET['original_name'])) {
    header('Location: index.php');
    exit;
}

$filename = $_GET['file'];
$original_name = $_GET['original_name'];
$filepath = UPLOAD_DIR . $filename;

// Verificar que el archivo existe
if (!file_exists($filepath)) {
    die("El archivo no existe en el servidor");
}

// Detectar el modelo a usar (puedes cambiar esto)
$model = 'small'; // Opciones: tiny, base, small, medium, large
$language = 'Spanish';

// Ejecutar Whisper
$output_dir = UPLOAD_DIR;
$base_name = pathinfo($filename, PATHINFO_FILENAME);
$output_file = $output_dir . $base_name . '.txt';
$srt_file = $output_dir . $base_name . '.srt';

// Construir comando para Whisper
$command = "whisper " . escapeshellarg($filepath) . 
           " --model " . $model . 
           " --language " . $language . 
           " --output_dir " . escapeshellarg($output_dir) .
           " 2>&1";

// Ejecutar y capturar la salida
exec($command, $output, $return_code);

// Leer el resultado
$text = '';
$srt_content = '';

if (file_exists($output_file)) {
    $text = file_get_contents($output_file);
}

if (file_exists($srt_file)) {
    $srt_content = file_get_contents($srt_file);
}

// Calcular estadísticas básicas
$word_count = str_word_count($text);

// Guardar en la base de datos
try {
    $sql = "INSERT INTO transcriptions (video_name, video_path, text, srt_subtitles, word_count, language, model_used) 
            VALUES (?, ?, ?, ?, ?, ?, ?)";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        $original_name,
        $filename,
        $text,
        $srt_content,
        $word_count,
        $language,
        $model
    ]);
    
    $id = $pdo->lastInsertId();
    
    // Redirigir a la vista
    header("Location: view.php?id=" . $id);
    exit;
    
} catch (PDOException $e) {
    die("Error al guardar en la base de datos: " . $e->getMessage());
}
?>

👁️ Ver Transcripción

8. view.php

php
<?php
require_once 'config/database.php';
include 'includes/header.php';

if (!isset($_GET['id'])) {
    header('Location: index.php');
    exit;
}

$id = (int)$_GET['id'];

// Obtener la transcripción
$sql = "SELECT * FROM transcriptions WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$id]);
$item = $stmt->fetch();

if (!$item) {
    header('Location: index.php');
    exit;
}
?>

<div class="row">
    <div class="col-12">
        <div class="card">
            <div class="card-header">
                <h2><i class="fas fa-file-alt"></i> Transcripción: <?php echo htmlspecialchars($item['video_name']); ?></h2>
                <div>
                    <a href="edit.php?id=<?php echo $id; ?>" class="btn btn-warning btn-sm">
                        <i class="fas fa-edit"></i> Editar
                    </a>
                    <a href="export.php?id=<?php echo $id; ?>&format=txt" class="btn btn-success btn-sm">
                        <i class="fas fa-download"></i> TXT
                    </a>
                    <a href="export.php?id=<?php echo $id; ?>&format=srt" class="btn btn-info btn-sm">
                        <i class="fas fa-download"></i> SRT
                    </a>
                    <a href="index.php" class="btn btn-secondary btn-sm">Volver</a>
                </div>
            </div>
            <div class="card-body">
                <div class="row mb-4">
                    <div class="col-md-3">
                        <strong><i class="fas fa-video"></i> Video:</strong>
                        <p><?php echo htmlspecialchars($item['video_name']); ?></p>
                    </div>
                    <div class="col-md-3">
                        <strong><i class="fas fa-language"></i> Idioma:</strong>
                        <p><?php echo $item['language']; ?></p>
                    </div>
                    <div class="col-md-3">
                        <strong><i class="fas fa-robot"></i> Modelo:</strong>
                        <p><?php echo $item['model_used']; ?></p>
                    </div>
                    <div class="col-md-3">
                        <strong><i class="fas fa-clock"></i> Creado:</strong>
                        <p><?php echo date('d/m/Y H:i', strtotime($item['created_at'])); ?></p>
                    </div>
                </div>
                
                <h4>Texto completo</h4>
                <div class="transcription-text">
                    <?php echo nl2br(htmlspecialchars($item['text'])); ?>
                </div>
                
                <?php if (!empty($item['srt_subtitles'])): ?>
                <hr>
                <h4>Subtítulos (SRT)</h4>
                <div class="transcription-text">
                    <pre><?php echo htmlspecialchars($item['srt_subtitles']); ?></pre>
                </div>
                <?php endif; ?>
            </div>
        </div>
    </div>
</div>

<?php include 'includes/footer.php'; ?>

✏️ Editar Transcripción

9. edit.php

php
<?php
require_once 'config/database.php';
include 'includes/header.php';

if (!isset($_GET['id'])) {
    header('Location: index.php');
    exit;
}

$id = (int)$_GET['id'];
$message = '';
$error = '';

// Obtener la transcripción
$sql = "SELECT * FROM transcriptions WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$id]);
$item = $stmt->fetch();

if (!$item) {
    header('Location: index.php');
    exit;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $text = trim($_POST['text']);
    
    if (empty($text)) {
        $error = "El texto no puede estar vacío";
    } else {
        $word_count = str_word_count($text);
        
        $update_sql = "UPDATE transcriptions SET text = ?, word_count = ?, updated_at = NOW() WHERE id = ?";
        $update_stmt = $pdo->prepare($update_sql);
        
        if ($update_stmt->execute([$text, $word_count, $id])) {
            $message = "Transcripción actualizada correctamente";
            // Recargar datos
            $stmt->execute([$id]);
            $item = $stmt->fetch();
        } else {
            $error = "Error al actualizar";
        }
    }
}
?>

<div class="row">
    <div class="col-12">
        <div class="card">
            <div class="card-header">
                <h2><i class="fas fa-edit"></i> Editar Transcripción</h2>
            </div>
            <div class="card-body">
                <?php if ($message): ?>
                    <div class="alert alert-success"><?php echo $message; ?></div>
                <?php endif; ?>
                
                <?php if ($error): ?>
                    <div class="alert alert-danger"><?php echo $error; ?></div>
                <?php endif; ?>
                
                <form action="" method="POST">
                    <div class="form-group">
                        <label><strong>Video:</strong> <?php echo htmlspecialchars($item['video_name']); ?></label>
                    </div>
                    
                    <div class="form-group">
                        <label for="text"><strong>Texto</strong></label>
                        <textarea name="text" id="text" class="form-control" rows="15" required><?php echo htmlspecialchars($item['text']); ?></textarea>
                    </div>
                    
                    <div class="form-group">
                        <button type="submit" class="btn btn-primary">
                            <i class="fas fa-save"></i> Guardar Cambios
                        </button>
                        <a href="view.php?id=<?php echo $id; ?>" class="btn btn-secondary">Cancelar</a>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

<?php include 'includes/footer.php'; ?>

📥 Exportar Transcripción

10. export.php

php
<?php
require_once 'config/database.php';

if (!isset($_GET['id']) || !isset($_GET['format'])) {
    header('Location: index.php');
    exit;
}

$id = (int)$_GET['id'];
$format = $_GET['format'];

// Obtener la transcripción
$sql = "SELECT * FROM transcriptions WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$id]);
$item = $stmt->fetch();

if (!$item) {
    header('Location: index.php');
    exit;
}

$filename = pathinfo($item['video_name'], PATHINFO_FILENAME);
$content = '';
$content_type = 'text/plain';
$extension = 'txt';

if ($format === 'srt') {
    $content = $item['srt_subtitles'] ?? "No hay subtítulos disponibles";
    $extension = 'srt';
} else {
    // Formato TXT por defecto
    $content = $item['text'];
    $extension = 'txt';
}

// Forzar descarga
header('Content-Type: ' . $content_type);
header('Content-Disposition: attachment; filename="' . $filename . '.' . $extension . '"');
header('Content-Length: ' . strlen($content));
header('Cache-Control: private, max-age=0, must-revalidate');
header('Pragma: public');

echo $content;
exit;
?>

🗑️ Eliminar Transcripción

11. delete.php

php
<?php
require_once 'config/database.php';

if (!isset($_GET['id'])) {
    header('Location: index.php');
    exit;
}

$id = (int)$_GET['id'];

// Obtener información del archivo
$sql = "SELECT video_path FROM transcriptions WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$id]);
$item = $stmt->fetch();

if ($item) {
    // Eliminar archivos físicos
    $filepath = UPLOAD_DIR . $item['video_path'];
    if (file_exists($filepath)) {
        unlink($filepath);
    }
    
    // Eliminar archivos de texto generados
    $base_name = pathinfo($item['video_path'], PATHINFO_FILENAME);
    $files_to_delete = [
        UPLOAD_DIR . $base_name . '.txt',
        UPLOAD_DIR . $base_name . '.srt',
        UPLOAD_DIR . $base_name . '.vtt',
        UPLOAD_DIR . $base_name . '.json'
    ];
    
    foreach ($files_to_delete as $file) {
        if (file_exists($file)) {
            unlink($file);
        }
    }
    
    // Eliminar de la base de datos
    $delete_sql = "DELETE FROM transcriptions WHERE id = ?";
    $delete_stmt = $pdo->prepare($delete_sql);
    $delete_stmt->execute([$id]);
}

header('Location: index.php');
exit;
?>

🎨 Estilos CSS

12. assets/css/style.css

css
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
    background: #f5f7fa;
    color: #333;
    display: flex;
    flex-direction: column;
    min-height: 100vh;
}

.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 0 20px;
    width: 100%;
}

/* Navbar */
.navbar {
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: white;
    padding: 15px 0;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}

.navbar .container {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.navbar-brand {
    color: white;
    text-decoration: none;
    font-size: 1.5rem;
    font-weight: bold;
}

.navbar-brand i {
    margin-right: 10px;
}

.nav-link {
    color: rgba(255,255,255,0.8);
    text-decoration: none;
    padding: 8px 15px;
    border-radius: 5px;
    transition: all 0.3s;
}

.nav-link:hover {
    color: white;
    background: rgba(255,255,255,0.1);
}

.navbar-nav {
    display: flex;
    gap: 10px;
}

/* Main content */
main {
    flex: 1;
    padding: 30px 0;
}

/* Cards */
.card {
    background: white;
    border-radius: 10px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.05);
    overflow: hidden;
    margin-bottom: 30px;
}

.card-header {
    padding: 20px 25px;
    border-bottom: 1px solid #e9ecef;
    display: flex;
    justify-content: space-between;
    align-items: center;
    flex-wrap: wrap;
    gap: 10px;
}

.card-header h2 {
    font-size: 1.25rem;
    margin: 0;
    display: flex;
    align-items: center;
    gap: 10px;
}

.card-body {
    padding: 25px;
}

/* Tables */
.table-responsive {
    overflow-x: auto;
}

.table {
    width: 100%;
    border-collapse: collapse;
}

.table th {
    background: #f8f9fa;
    padding: 12px 15px;
    text-align: left;
    font-weight: 600;
    font-size: 0.9rem;
    border-bottom: 2px solid #dee2e6;
}

.table td {
    padding: 12px 15px;
    border-bottom: 1px solid #e9ecef;
    vertical-align: middle;
}

.table tr:hover {
    background: #f8f9fa;
}

/* Buttons */
.btn {
    display: inline-block;
    padding: 8px 16px;
    border-radius: 5px;
    border: none;
    cursor: pointer;
    text-decoration: none;
    font-size: 0.9rem;
    transition: all 0.3s;
    font-weight: 500;
}

.btn-primary {
    background: #667eea;
    color: white;
}

.btn-primary:hover {
    background: #5a67d8;
    transform: translateY(-2px);
    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}

.btn-success {
    background: #48bb78;
    color: white;
}

.btn-success:hover {
    background: #38a169;
}

.btn-info {
    background: #4fd1c5;
    color: white;
}

.btn-info:hover {
    background: #38b2ac;
}

.btn-warning {
    background: #ed8936;
    color: white;
}

.btn-warning:hover {
    background: #dd6b20;
}

.btn-danger {
    background: #fc8181;
    color: white;
}

.btn-danger:hover {
    background: #f56565;
}

.btn-secondary {
    background: #a0aec0;
    color: white;
}

.btn-secondary:hover {
    background: #718096;
}

.btn-block {
    width: 100%;
    padding: 12px;
}

.btn-sm {
    padding: 5px 10px;
    font-size: 0.8rem;
}

.btn-group {
    display: flex;
    gap: 5px;
    flex-wrap: wrap;
}

/* Forms */
.form-group {
    margin-bottom: 20px;
}

.form-group label {
    display: block;
    margin-bottom: 5px;
    font-weight: 500;
}

.form-control {
    width: 100%;
    padding: 10px 15px;
    border: 1px solid #d1d5db;
    border-radius: 5px;
    font-size: 1rem;
    transition: border-color 0.3s;
}

.form-control:focus {
    outline: none;
    border-color: #667eea;
    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

textarea.form-control {
    font-family: 'Courier New', monospace;
    resize: vertical;
}

/* Drop zone */
.drop-zone {
    display: block;
    padding: 60px 20px;
    text-align: center;
    border: 2px dashed #d1d5db;
    border-radius: 10px;
    cursor: pointer;
    transition: all 0.3s;
    background: #fafafa;
}

.drop-zone:hover {
    border-color: #667eea;
    background: #f7f7ff;
}

.drop-zone i {
    color: #667eea;
}

.drop-zone h3 {
    margin: 15px 0 5px;
    font-size: 1.2rem;
}

.drop-zone p {
    margin: 5px 0;
    color: #6b7280;
}

.drop-zone small {
    color: #9ca3af;
}

.drop-zone input[type="file"] {
    display: none;
}

/* Badge */
.badge {
    display: inline-block;
    padding: 3px 10px;
    background: #e9ecef;
    border-radius: 20px;
    font-size: 0.8rem;
    font-weight: 500;
}

/* Alerts */
.alert {
    padding: 15px 20px;
    border-radius: 5px;
    margin-bottom: 20px;
}

.alert-success {
    background: #d4edda;
    color: #155724;
    border: 1px solid #c3e6cb;
}

.alert-danger {
    background: #f8d7da;
    color: #721c24;
    border: 1px solid #f5c6cb;
}

/* Empty state */
.empty-state {
    text-align: center;
    padding: 60px 20px;
}

.empty-state i {
    color: #d1d5db;
    margin-bottom: 20px;
}

.empty-state h3 {
    margin-bottom: 10px;
    color: #374151;
}

.empty-state p {
    color: #6b7280;
    margin-bottom: 20px;
}

/* File info */
.file-info {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 15px;
    background: #f8f9fa;
    border-radius: 5px;
}

.file-info i {
    color: #667eea;
    font-size: 1.5rem;
}

/* Transcription text */
.transcription-text {
    background: #f8f9fa;
    padding: 20px;
    border-radius: 5px;
    max-height: 500px;
    overflow-y: auto;
    white-space: pre-wrap;
    word-wrap: break-word;
    font-family: 'Courier New', monospace;
    font-size: 0.95rem;
    line-height: 1.6;
}

.transcription-text pre {
    background: transparent;
    border: none;
    padding: 0;
    margin: 0;
    font-family: inherit;
}

/* Footer */
.footer {
    background: white;
    padding: 20px 0;
    margin-top: auto;
    border-top: 1px solid #e9ecef;
    text-align: center;
    color: #6b7280;
}

/* Responsive */
@media (max-width: 768px) {
    .navbar .container {
        flex-direction: column;
        gap: 10px;
    }
    
    .card-header {
        flex-direction: column;
        align-items: flex-start;
    }
    
    .table th, .table td {
        padding: 8px 10px;
        font-size: 0.85rem;
    }
    
    .btn-group {
        flex-direction: column;
    }
    
    .btn-group .btn {
        width: 100%;
    }
    
    .col-md-3, .col-md-8, .col-md-6 {
        width: 100%;
    }
}

/* Grid */
.row {
    display: flex;
    flex-wrap: wrap;
    margin: 0 -15px;
}

.col-12 {
    width: 100%;
    padding: 0 15px;
}

.col-md-3 {
    width: 25%;
    padding: 0 15px;
}

.col-md-6 {
    width: 50%;
    padding: 0 15px;
}

.col-md-8 {
    width: 66.666%;
    padding: 0 15px;
}

@media (max-width: 768px) {
    .col-md-3, .col-md-6, .col-md-8 {
        width: 100%;
    }
}

.mt-4 { margin-top: 2rem; }
.mb-4 { margin-bottom: 2rem; }
.mr-2 { margin-right: 0.5rem; }

🚀 Instalación Rápida

1. Requisitos del Servidor

bash
# Instalar PHP y MySQL (en Ubuntu/Debian)
sudo apt update
sudo apt install php php-mysql php-mbstring php-xml php-curl php-zip php-gd php-intl
sudo apt install mysql-server

# Instalar Python y Whisper
sudo apt install python3 python3-pip ffmpeg
pip3 install openai-whisper

2. Configurar la Base de Datos

bash
sudo mysql -u root -p
# Ejecuta el SQL de creación de la base de datos

3. Configurar el Proyecto

bash
# Copia los archivos al servidor web
sudo cp -r whisper-app /var/www/html/
sudo chown -R www-data:www-data /var/www/html/whisper-app/assets/uploads/
sudo chmod 755 /var/www/html/whisper-app/assets/uploads/

4. Configurar Apache/Nginx

Asegúrate de que el directorio raíz apunte a la carpeta del proyecto.

5. ¡Listo!

Visita http://localhost/whisper-app/index.php y comienza a transcribir.


🔒 Mejoras de Seguridad Sugeridas

php
// Agregar estas funciones a config/database.php

// Sanitizar entradas
function sanitize($input) {
    return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8');
}

// Validar archivos
function isVideoFile($filename) {
    $allowed = ['mp4', 'mkv', 'avi', 'mov', 'webm'];
    $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
    return in_array($ext, $allowed);
}

// Limitar acceso (agregar autenticación básica)
function requireLogin() {
    if (!isset($_SESSION['user_id'])) {
        header('Location: login.php');
        exit;
    }
}

¡Y eso es todo! Ahora tienes una aplicación completa para gestionar tus transcripciones de Whisper. La interfaz es intuitiva, y puedes expandirla fácilmente añadiendo más funciones como búsqueda, categorías, o incluso un reproductor de video incrustado.

Comentarios

Entradas más populares de este blog

Cómo usar Whisper para sacar el texto de un video

Tutorial 18: Creando la Página de Visualización de Archivos Recientes

Tutorial 11: ¿Qué es Whisper AI y Cómo Funciona?