first draft videos
This commit is contained in:
		
							parent
							
								
									d6760d0f84
								
							
						
					
					
						commit
						f159f904f0
					
				
							
								
								
									
										35
									
								
								.env.example
									
									
									
									
									
								
							
							
						
						
									
										35
									
								
								.env.example
									
									
									
									
									
								
							@ -68,6 +68,41 @@ AI_EMBEDDINGS_MODEL=mistral-embed
 | 
			
		||||
# User rate limiting (queries per minute)
 | 
			
		||||
AI_RATE_LIMIT_MAX_REQUESTS=4
 | 
			
		||||
 | 
			
		||||
# ============================================================================
 | 
			
		||||
# 🎥 VIDEO EMBEDDING - PRODUCTION CONFIGURATION
 | 
			
		||||
# ============================================================================
 | 
			
		||||
 | 
			
		||||
# Enable local caching of Nextcloud videos (highly recommended)
 | 
			
		||||
VIDEO_CACHE_ENABLED=true
 | 
			
		||||
 | 
			
		||||
# Directory for cached videos (ensure it's writable and has sufficient space)
 | 
			
		||||
# This directory will grow over time as videos are cached permanently
 | 
			
		||||
VIDEO_CACHE_DIR=./cache/videos
 | 
			
		||||
 | 
			
		||||
# Emergency cleanup threshold in MB - videos are cached indefinitely
 | 
			
		||||
# Only triggers cleanup when approaching this limit to prevent disk full
 | 
			
		||||
# Recommended: 2000MB (2GB) for small deployments, 5000MB+ for larger ones
 | 
			
		||||
VIDEO_CACHE_MAX_SIZE=2000
 | 
			
		||||
 | 
			
		||||
# Maximum individual video file size for caching in MB
 | 
			
		||||
# Videos larger than this will stream directly without caching
 | 
			
		||||
VIDEO_MAX_SIZE=200
 | 
			
		||||
 | 
			
		||||
MINIO_URL=http://127.0.0.1:9000
 | 
			
		||||
MINIO_ACCESS_KEY=your-access-key
 | 
			
		||||
MINIO_SECRET_KEY=your-secret-key
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# ============================================================================
 | 
			
		||||
# CACHING BEHAVIOR
 | 
			
		||||
# ============================================================================
 | 
			
		||||
# - Videos downloaded once, cached permanently
 | 
			
		||||
# - No time-based expiration
 | 
			
		||||
# - Dramatically improves loading times after first download
 | 
			
		||||
# - Emergency cleanup only when approaching disk space limit
 | 
			
		||||
# - Perfect for manually curated forensics training content
 | 
			
		||||
# ============================================================================
 | 
			
		||||
 | 
			
		||||
# ============================================================================
 | 
			
		||||
# 🎛️ PERFORMANCE TUNING - SENSIBLE DEFAULTS PROVIDED
 | 
			
		||||
# ============================================================================
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { defineConfig } from 'astro/config';
 | 
			
		||||
import node from '@astrojs/node';
 | 
			
		||||
import { remarkVideoPlugin } from './src/utils/remarkVideoPlugin.ts';
 | 
			
		||||
 | 
			
		||||
export default defineConfig({
 | 
			
		||||
  output: 'server',
 | 
			
		||||
@ -7,6 +8,22 @@ export default defineConfig({
 | 
			
		||||
    mode: 'standalone'
 | 
			
		||||
  }),
 | 
			
		||||
  
 | 
			
		||||
  markdown: {
 | 
			
		||||
    remarkPlugins: [
 | 
			
		||||
      [remarkVideoPlugin, {
 | 
			
		||||
        enableAsync: true,
 | 
			
		||||
        defaultOptions: {
 | 
			
		||||
          controls: true,
 | 
			
		||||
          autoplay: false,
 | 
			
		||||
          muted: false,
 | 
			
		||||
          aspectRatio: '16:9',
 | 
			
		||||
          showMetadata: true
 | 
			
		||||
        }
 | 
			
		||||
      }]
 | 
			
		||||
    ],
 | 
			
		||||
    extendDefaultPlugins: true
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  build: {
 | 
			
		||||
    assets: '_astro'
 | 
			
		||||
  },
 | 
			
		||||
@ -16,4 +33,4 @@ export default defineConfig({
 | 
			
		||||
    host: true
 | 
			
		||||
  },
 | 
			
		||||
  allowImportingTsExtensions: true
 | 
			
		||||
});
 | 
			
		||||
});
 | 
			
		||||
@ -11,6 +11,8 @@
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@astrojs/node": "^9.3.0",
 | 
			
		||||
    "@aws-sdk/client-s3": "^3.864.0",
 | 
			
		||||
    "@aws-sdk/s3-request-presigner": "^3.864.0",
 | 
			
		||||
    "astro": "^5.12.3",
 | 
			
		||||
    "cookie": "^1.0.2",
 | 
			
		||||
    "dotenv": "^16.4.5",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										185
									
								
								src/components/Video.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								src/components/Video.astro
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,185 @@
 | 
			
		||||
---
 | 
			
		||||
// src/components/Video.astro
 | 
			
		||||
import { videoProcessor, type VideoMetadata } from '../utils/videoUtils.js';
 | 
			
		||||
 | 
			
		||||
export interface Props {
 | 
			
		||||
  src: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  controls?: boolean;
 | 
			
		||||
  autoplay?: boolean;
 | 
			
		||||
  muted?: boolean;
 | 
			
		||||
  loop?: boolean;
 | 
			
		||||
  preload?: 'none' | 'metadata' | 'auto';
 | 
			
		||||
  aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
  showMetadata?: boolean;
 | 
			
		||||
  poster?: string;
 | 
			
		||||
  width?: string;
 | 
			
		||||
  height?: string;
 | 
			
		||||
  fallback?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
  src,
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  controls = true,
 | 
			
		||||
  autoplay = false,
 | 
			
		||||
  muted = false,
 | 
			
		||||
  loop = false,
 | 
			
		||||
  preload = 'metadata',
 | 
			
		||||
  aspectRatio = '16:9',
 | 
			
		||||
  showMetadata = true,
 | 
			
		||||
  poster,
 | 
			
		||||
  width,
 | 
			
		||||
  height,
 | 
			
		||||
  fallback
 | 
			
		||||
} = Astro.props;
 | 
			
		||||
 | 
			
		||||
// Process the video URL and generate optimized sources
 | 
			
		||||
const metadata: Partial<VideoMetadata> = {
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  poster
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const options = {
 | 
			
		||||
  controls,
 | 
			
		||||
  autoplay,
 | 
			
		||||
  muted,
 | 
			
		||||
  loop,
 | 
			
		||||
  preload,
 | 
			
		||||
  aspectRatio,
 | 
			
		||||
  showMetadata,
 | 
			
		||||
  width,
 | 
			
		||||
  height
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
let processedVideo;
 | 
			
		||||
let videoHTML = '';
 | 
			
		||||
let errorMessage = '';
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
  processedVideo = await videoProcessor.processVideoUrl(src, metadata);
 | 
			
		||||
  videoHTML = videoProcessor.generateVideoHTML(processedVideo, options);
 | 
			
		||||
} catch (error) {
 | 
			
		||||
  console.error('[VIDEO COMPONENT] Processing failed:', error);
 | 
			
		||||
  errorMessage = error.message;
 | 
			
		||||
  videoHTML = `
 | 
			
		||||
    <div class="video-container aspect-${aspectRatio}">
 | 
			
		||||
      <div class="video-error">
 | 
			
		||||
        <div class="error-icon">⚠️</div>
 | 
			
		||||
        <div>${fallback || `Video could not be loaded: ${errorMessage}`}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  `;
 | 
			
		||||
}
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<Fragment set:html={videoHTML} />
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
  // Client-side video enhancement
 | 
			
		||||
  document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
    const videos = document.querySelectorAll('.video-container video') as NodeListOf<HTMLVideoElement>;
 | 
			
		||||
    
 | 
			
		||||
    videos.forEach((video: HTMLVideoElement) => {
 | 
			
		||||
      const container = video.closest('.video-container') as HTMLElement;
 | 
			
		||||
      if (!container) return;
 | 
			
		||||
      
 | 
			
		||||
      // Add loading state
 | 
			
		||||
      video.addEventListener('loadstart', () => {
 | 
			
		||||
        container.classList.add('loading');
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      video.addEventListener('loadeddata', () => {
 | 
			
		||||
        container.classList.remove('loading');
 | 
			
		||||
        container.classList.add('loaded');
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      video.addEventListener('error', (e) => {
 | 
			
		||||
        console.error('[VIDEO] Load error:', e);
 | 
			
		||||
        container.classList.remove('loading');
 | 
			
		||||
        container.classList.add('error');
 | 
			
		||||
        
 | 
			
		||||
        // Show error message
 | 
			
		||||
        const errorDiv = document.createElement('div');
 | 
			
		||||
        errorDiv.className = 'video-error';
 | 
			
		||||
        errorDiv.innerHTML = `
 | 
			
		||||
          <div class="error-icon">⚠️</div>
 | 
			
		||||
          <div>Video could not be loaded</div>
 | 
			
		||||
        `;
 | 
			
		||||
        
 | 
			
		||||
        (video as HTMLElement).style.display = 'none';
 | 
			
		||||
        container.appendChild(errorDiv);
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Handle fullscreen
 | 
			
		||||
      video.addEventListener('dblclick', () => {
 | 
			
		||||
        const videoElement = video as any;
 | 
			
		||||
        if (video.requestFullscreen) {
 | 
			
		||||
          video.requestFullscreen();
 | 
			
		||||
        } else if (videoElement.webkitRequestFullscreen) {
 | 
			
		||||
          videoElement.webkitRequestFullscreen();
 | 
			
		||||
        } else if (videoElement.msRequestFullscreen) {
 | 
			
		||||
          videoElement.msRequestFullscreen();
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Add keyboard shortcuts
 | 
			
		||||
      video.addEventListener('keydown', (e: KeyboardEvent) => {
 | 
			
		||||
        switch(e.key) {
 | 
			
		||||
          case ' ':
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            if (video.paused) {
 | 
			
		||||
              video.play();
 | 
			
		||||
            } else {
 | 
			
		||||
              video.pause();
 | 
			
		||||
            }
 | 
			
		||||
            break;
 | 
			
		||||
          case 'f':
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            if (video.requestFullscreen) {
 | 
			
		||||
              video.requestFullscreen();
 | 
			
		||||
            }
 | 
			
		||||
            break;
 | 
			
		||||
          case 'm':
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            video.muted = !video.muted;
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
  /* Component-specific enhancements */
 | 
			
		||||
  .video-container.loading video {
 | 
			
		||||
    opacity: 0.5;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .video-container.loading::after {
 | 
			
		||||
    content: '';
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 50%;
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    transform: translate(-50%, -50%);
 | 
			
		||||
    width: 2rem;
 | 
			
		||||
    height: 2rem;
 | 
			
		||||
    border: 3px solid var(--color-border);
 | 
			
		||||
    border-top: 3px solid var(--color-primary);
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
    animation: spin 1s linear infinite;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .video-container.error video {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  @keyframes spin {
 | 
			
		||||
    0% { transform: translate(-50%, -50%) rotate(0deg); }
 | 
			
		||||
    100% { transform: translate(-50%, -50%) rotate(360deg); }
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										142
									
								
								src/pages/api/video/cached/[...path].ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								src/pages/api/video/cached/[...path].ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,142 @@
 | 
			
		||||
// src/pages/api/video/cached/[...path].ts - Production video serving only
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
 | 
			
		||||
export const GET: APIRoute = async ({ params, request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const videoPath = params.path;
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO SERVE] Request for cached video: ${videoPath}`);
 | 
			
		||||
    
 | 
			
		||||
    if (!videoPath || typeof videoPath !== 'string') {
 | 
			
		||||
      console.warn('[VIDEO SERVE] Invalid video path provided');
 | 
			
		||||
      return new Response('Video not found', { status: 404 });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Security: Prevent path traversal
 | 
			
		||||
    const safePath = path.normalize(videoPath).replace(/^(\.\.[\/\\])+/, '');
 | 
			
		||||
    const cacheDir = process.env.VIDEO_CACHE_DIR || './cache/videos';
 | 
			
		||||
    const fullPath = path.join(cacheDir, safePath);
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO SERVE] Resolved cache path: ${fullPath}`);
 | 
			
		||||
    
 | 
			
		||||
    // Ensure the requested file is within the cache directory
 | 
			
		||||
    if (!fullPath.startsWith(path.resolve(cacheDir))) {
 | 
			
		||||
      console.error(`[VIDEO SERVE] Path traversal attempt blocked: ${fullPath}`);
 | 
			
		||||
      return new Response('Access denied', { status: 403 });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const stat = await fs.stat(fullPath);
 | 
			
		||||
      
 | 
			
		||||
      if (!stat.isFile()) {
 | 
			
		||||
        console.warn(`[VIDEO SERVE] Requested path is not a file: ${fullPath}`);
 | 
			
		||||
        return new Response('Video not found', { status: 404 });
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO SERVE] Serving cached video: ${safePath} (${Math.round(stat.size / 1024 / 1024)}MB)`);
 | 
			
		||||
      
 | 
			
		||||
      // Update access time for LRU tracking (for emergency cleanup)
 | 
			
		||||
      const now = new Date();
 | 
			
		||||
      await fs.utimes(fullPath, now, stat.mtime).catch((err) => {
 | 
			
		||||
        console.warn(`[VIDEO SERVE] Failed to update access time for ${safePath}: ${err.message}`);
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Determine content type
 | 
			
		||||
      const ext = path.extname(fullPath).toLowerCase();
 | 
			
		||||
      const contentType = getVideoMimeType(ext);
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO SERVE] Content type: ${contentType}, File size: ${stat.size} bytes`);
 | 
			
		||||
      
 | 
			
		||||
      // Handle range requests for video streaming
 | 
			
		||||
      const range = request.headers.get('range');
 | 
			
		||||
      const fileSize = stat.size;
 | 
			
		||||
      
 | 
			
		||||
      if (range) {
 | 
			
		||||
        console.log(`[VIDEO SERVE] Range request: ${range}`);
 | 
			
		||||
        
 | 
			
		||||
        // Parse range header
 | 
			
		||||
        const rangeMatch = range.match(/bytes=(\d+)-(\d*)/);
 | 
			
		||||
        if (rangeMatch) {
 | 
			
		||||
          const start = parseInt(rangeMatch[1]);
 | 
			
		||||
          const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : fileSize - 1;
 | 
			
		||||
          const chunkSize = end - start + 1;
 | 
			
		||||
          
 | 
			
		||||
          console.log(`[VIDEO SERVE] Range: ${start}-${end}, chunk size: ${chunkSize}`);
 | 
			
		||||
          
 | 
			
		||||
          if (start >= fileSize || end >= fileSize || start > end) {
 | 
			
		||||
            console.warn(`[VIDEO SERVE] Invalid range: ${start}-${end} for file size ${fileSize}`);
 | 
			
		||||
            return new Response('Range not satisfiable', { 
 | 
			
		||||
              status: 416,
 | 
			
		||||
              headers: {
 | 
			
		||||
                'Content-Range': `bytes */${fileSize}`
 | 
			
		||||
              }
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
          
 | 
			
		||||
          const fileStream = await fs.readFile(fullPath);
 | 
			
		||||
          const chunk = fileStream.slice(start, end + 1);
 | 
			
		||||
          
 | 
			
		||||
          console.log(`[VIDEO SERVE] Serving partial content: ${chunk.length} bytes`);
 | 
			
		||||
          
 | 
			
		||||
          return new Response(chunk, {
 | 
			
		||||
            status: 206,
 | 
			
		||||
            headers: {
 | 
			
		||||
              'Content-Type': contentType,
 | 
			
		||||
              'Content-Length': chunkSize.toString(),
 | 
			
		||||
              'Content-Range': `bytes ${start}-${end}/${fileSize}`,
 | 
			
		||||
              'Accept-Ranges': 'bytes',
 | 
			
		||||
              'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
 | 
			
		||||
              'Last-Modified': stat.mtime.toUTCString()
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Serve entire file
 | 
			
		||||
      console.log(`[VIDEO SERVE] Serving complete file: ${safePath}`);
 | 
			
		||||
      const fileBuffer = await fs.readFile(fullPath);
 | 
			
		||||
      
 | 
			
		||||
      return new Response(fileBuffer, {
 | 
			
		||||
        status: 200,
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': contentType,
 | 
			
		||||
          'Content-Length': fileSize.toString(),
 | 
			
		||||
          'Accept-Ranges': 'bytes',
 | 
			
		||||
          'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
 | 
			
		||||
          'Last-Modified': stat.mtime.toUTCString()
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error.code === 'ENOENT') {
 | 
			
		||||
        console.warn(`[VIDEO SERVE] File not found: ${fullPath}`);
 | 
			
		||||
        return new Response('Video not found', { status: 404 });
 | 
			
		||||
      }
 | 
			
		||||
      console.error(`[VIDEO SERVE] File system error for ${fullPath}:`, error);
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('[VIDEO SERVE] Unexpected error serving cached video:', error);
 | 
			
		||||
    return new Response('Internal server error', { status: 500 });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function getVideoMimeType(extension: string): string {
 | 
			
		||||
  const mimeTypes: Record<string, string> = {
 | 
			
		||||
    '.mp4': 'video/mp4',
 | 
			
		||||
    '.webm': 'video/webm',
 | 
			
		||||
    '.ogg': 'video/ogg',
 | 
			
		||||
    '.mov': 'video/quicktime',
 | 
			
		||||
    '.avi': 'video/x-msvideo',
 | 
			
		||||
    '.m4v': 'video/x-m4v',
 | 
			
		||||
    '.3gp': 'video/3gpp'
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  const mimeType = mimeTypes[extension] || 'application/octet-stream';
 | 
			
		||||
  console.log(`[VIDEO SERVE] MIME type for ${extension}: ${mimeType}`);
 | 
			
		||||
  
 | 
			
		||||
  return mimeType;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								src/pages/api/video/process.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/pages/api/video/process.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
// src/pages/api/video/process.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { videoProcessor, type VideoMetadata } from '../../../utils/videoUtils.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError } from '../../../utils/api.js';
 | 
			
		||||
 | 
			
		||||
export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const body = await request.json().catch(() => null);
 | 
			
		||||
    
 | 
			
		||||
    if (!body) {
 | 
			
		||||
      return apiError.badRequest('Request body must be valid JSON');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const { url, metadata = {}, options = {} } = body;
 | 
			
		||||
    
 | 
			
		||||
    if (!url || typeof url !== 'string') {
 | 
			
		||||
      return apiError.badRequest('Video URL is required');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Validate URL
 | 
			
		||||
    try {
 | 
			
		||||
      new URL(url);
 | 
			
		||||
    } catch {
 | 
			
		||||
      return apiError.badRequest('Invalid video URL format');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO API] Processing video: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    const processedVideo = await videoProcessor.processVideoUrl(url, metadata as Partial<VideoMetadata>);
 | 
			
		||||
    const html = videoProcessor.generateVideoHTML(processedVideo, options);
 | 
			
		||||
    
 | 
			
		||||
    return apiResponse.success({
 | 
			
		||||
      processedVideo,
 | 
			
		||||
      html,
 | 
			
		||||
      cached: processedVideo.sources.some(s => s.cached),
 | 
			
		||||
      requiresAuth: processedVideo.requiresAuth
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('[VIDEO API] Processing error:', error);
 | 
			
		||||
    return apiServerError.internal(`Video processing failed: ${error.message}`);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -688,3 +688,245 @@
 | 
			
		||||
  /* Expand content */
 | 
			
		||||
  .article-main { max-width: 100% !important; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/* ==========================================================================
 | 
			
		||||
   VIDEO EMBEDDING - Add to knowledgebase.css
 | 
			
		||||
   ========================================================================== */
 | 
			
		||||
 | 
			
		||||
/* Video Container and Responsive Wrapper */
 | 
			
		||||
:where(.markdown-content) .video-container {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  margin: 2rem 0;
 | 
			
		||||
  border-radius: var(--radius-lg, 0.75rem);
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  background-color: var(--color-bg-tertiary, #000);
 | 
			
		||||
  box-shadow: var(--shadow-lg, 0 12px 30px rgba(0,0,0,0.16));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Responsive 16:9 aspect ratio by default */
 | 
			
		||||
:where(.markdown-content) .video-container.aspect-16-9 {
 | 
			
		||||
  aspect-ratio: 16 / 9;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container.aspect-4-3 {
 | 
			
		||||
  aspect-ratio: 4 / 3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container.aspect-1-1 {
 | 
			
		||||
  aspect-ratio: 1 / 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Element Styling */
 | 
			
		||||
:where(.markdown-content) .video-container video {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  object-fit: contain;
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
  border: none;
 | 
			
		||||
  outline: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Custom Video Controls Enhancement */
 | 
			
		||||
:where(.markdown-content) video::-webkit-media-controls-panel {
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.8);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) video::-webkit-media-controls-current-time-display,
 | 
			
		||||
:where(.markdown-content) video::-webkit-media-controls-time-remaining-display {
 | 
			
		||||
  color: white;
 | 
			
		||||
  text-shadow: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Loading State */
 | 
			
		||||
:where(.markdown-content) .video-container .video-loading {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
  color: var(--color-text-secondary);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container .video-loading .spinner {
 | 
			
		||||
  width: 2rem;
 | 
			
		||||
  height: 2rem;
 | 
			
		||||
  border: 3px solid var(--color-border);
 | 
			
		||||
  border-top: 3px solid var(--color-primary);
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  animation: spin 1s linear infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes spin {
 | 
			
		||||
  0% { transform: rotate(0deg); }
 | 
			
		||||
  100% { transform: rotate(360deg); }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Error State */
 | 
			
		||||
:where(.markdown-content) .video-container .video-error {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: var(--color-error, #dc3545);
 | 
			
		||||
  padding: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container .video-error .error-icon {
 | 
			
		||||
  font-size: 3rem;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Metadata Overlay */
 | 
			
		||||
:where(.markdown-content) .video-metadata {
 | 
			
		||||
  background-color: var(--color-bg-secondary);
 | 
			
		||||
  border: 1px solid var(--color-border);
 | 
			
		||||
  border-top: none;
 | 
			
		||||
  padding: 1rem 1.5rem;
 | 
			
		||||
  font-size: 0.875rem;
 | 
			
		||||
  color: var(--color-text-secondary);
 | 
			
		||||
  border-radius: 0 0 var(--radius-lg, 0.75rem) var(--radius-lg, 0.75rem);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-title {
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
  color: var(--color-text);
 | 
			
		||||
  margin-bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-info {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 1rem;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-duration,
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-size,
 | 
			
		||||
:where(.markdown-content) .video-metadata .video-format {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Fullscreen Support */
 | 
			
		||||
:where(.markdown-content) .video-container video:fullscreen {
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container video:-webkit-full-screen {
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container video:-moz-full-screen {
 | 
			
		||||
  background-color: #000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Thumbnail/Poster Styling */
 | 
			
		||||
:where(.markdown-content) .video-container video[poster] {
 | 
			
		||||
  object-fit: cover;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Protected Video Overlay */
 | 
			
		||||
:where(.markdown-content) .video-container .video-protected {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.8);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  color: white;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  padding: 2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-container .video-protected .lock-icon {
 | 
			
		||||
  font-size: 3rem;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  opacity: 0.8;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Responsive Design */
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  :where(.markdown-content) .video-container {
 | 
			
		||||
    margin: 1.5rem -0.5rem; /* Extend to edges on mobile */
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  :where(.markdown-content) .video-metadata {
 | 
			
		||||
    padding: 0.75rem 1rem;
 | 
			
		||||
    font-size: 0.8125rem;
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  :where(.markdown-content) .video-metadata .video-info {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    gap: 0.5rem;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Dark Theme Adjustments */
 | 
			
		||||
[data-theme="dark"] :where(.markdown-content) .video-container {
 | 
			
		||||
  box-shadow: 0 12px 30px rgba(0,0,0,0.4);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
[data-theme="dark"] :where(.markdown-content) .video-metadata {
 | 
			
		||||
  background-color: var(--color-bg-tertiary);
 | 
			
		||||
  border-color: color-mix(in srgb, var(--color-border) 60%, transparent);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Caption/Description Support */
 | 
			
		||||
:where(.markdown-content) .video-caption {
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
  font-size: 0.9375rem;
 | 
			
		||||
  color: var(--color-text-secondary);
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
  line-height: 1.5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Video Gallery Support (multiple videos) */
 | 
			
		||||
:where(.markdown-content) .video-gallery {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
 | 
			
		||||
  gap: 2rem;
 | 
			
		||||
  margin: 2rem 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
:where(.markdown-content) .video-gallery .video-container {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Accessibility Improvements */
 | 
			
		||||
:where(.markdown-content) .video-container video:focus {
 | 
			
		||||
  outline: 3px solid var(--color-primary);
 | 
			
		||||
  outline-offset: 3px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Print Media - Hide Videos */
 | 
			
		||||
@media print {
 | 
			
		||||
  :where(.markdown-content) .video-container {
 | 
			
		||||
    display: none !important;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  :where(.markdown-content) .video-container::after {
 | 
			
		||||
    content: "[Video: " attr(data-video-title, "Embedded Video") "]";
 | 
			
		||||
    display: block;
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
    background-color: #f5f5f5;
 | 
			
		||||
    border: 1px solid #ddd;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    font-style: italic;
 | 
			
		||||
    color: #666;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										426
									
								
								src/utils/remarkVideoPlugin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										426
									
								
								src/utils/remarkVideoPlugin.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,426 @@
 | 
			
		||||
// src/utils/remarkVideoPlugin.ts
 | 
			
		||||
import { visit } from 'unist-util-visit';
 | 
			
		||||
import type { Plugin } from 'unified';
 | 
			
		||||
import type { Root, Text, Element } from 'hast';
 | 
			
		||||
import { videoProcessor } from './videoUtils.js';
 | 
			
		||||
 | 
			
		||||
interface VideoConfig {
 | 
			
		||||
  enableAsync?: boolean;
 | 
			
		||||
  defaultOptions?: {
 | 
			
		||||
    controls?: boolean;
 | 
			
		||||
    autoplay?: boolean;
 | 
			
		||||
    muted?: boolean;
 | 
			
		||||
    aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
    showMetadata?: boolean;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Remark plugin to transform video syntax in markdown to HTML video elements
 | 
			
		||||
 * 
 | 
			
		||||
 * Supports multiple syntaxes:
 | 
			
		||||
 * 1. Custom video directive: :::video{src="url" title="Title" controls autoplay}
 | 
			
		||||
 * 2. Image syntax for videos: 
 | 
			
		||||
 * 3. HTML video tags: <video src="url"></video>
 | 
			
		||||
 * 4. Link syntax with video: [Video Title](url.mp4)
 | 
			
		||||
 */
 | 
			
		||||
export const remarkVideoPlugin: Plugin<[VideoConfig?], Root> = (config = {}) => {
 | 
			
		||||
  const {
 | 
			
		||||
    enableAsync = true,
 | 
			
		||||
    defaultOptions = {
 | 
			
		||||
      controls: true,
 | 
			
		||||
      autoplay: false,
 | 
			
		||||
      muted: false,
 | 
			
		||||
      aspectRatio: '16:9',
 | 
			
		||||
      showMetadata: true
 | 
			
		||||
    }
 | 
			
		||||
  } = config;
 | 
			
		||||
 | 
			
		||||
  return async (tree: Root) => {
 | 
			
		||||
    const videoNodes: Array<{ node: any; parent: any; index: number; replacement: any }> = [];
 | 
			
		||||
 | 
			
		||||
    // Find video directives (:::video{...})
 | 
			
		||||
    visit(tree, 'textDirective', (node: any, index: number | undefined, parent: any) => {
 | 
			
		||||
      if (node.name === 'video' && typeof index === 'number') {
 | 
			
		||||
        const videoNode = processVideoDirective(node, defaultOptions);
 | 
			
		||||
        if (videoNode) {
 | 
			
		||||
          videoNodes.push({ node, parent, index, replacement: videoNode });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Find container directives (:::video ... :::)
 | 
			
		||||
    visit(tree, 'containerDirective', (node: any, index: number | undefined, parent: any) => {
 | 
			
		||||
      if (node.name === 'video' && typeof index === 'number') {
 | 
			
		||||
        const videoNode = processVideoDirective(node, defaultOptions);
 | 
			
		||||
        if (videoNode) {
 | 
			
		||||
          videoNodes.push({ node, parent, index, replacement: videoNode });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Find image nodes that might be videos
 | 
			
		||||
    visit(tree, 'image', (node: any, index: number | undefined, parent: any) => {
 | 
			
		||||
      if (isVideoUrl(node.url) && typeof index === 'number') {
 | 
			
		||||
        const videoNode = processImageAsVideo(node, defaultOptions);
 | 
			
		||||
        if (videoNode) {
 | 
			
		||||
          videoNodes.push({ node, parent, index, replacement: videoNode });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Find link nodes that point to videos
 | 
			
		||||
    visit(tree, 'link', (node: any, index: number | undefined, parent: any) => {
 | 
			
		||||
      if (isVideoUrl(node.url) && typeof index === 'number') {
 | 
			
		||||
        const videoNode = processLinkAsVideo(node, defaultOptions);
 | 
			
		||||
        if (videoNode) {
 | 
			
		||||
          videoNodes.push({ node, parent, index, replacement: videoNode });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Process HTML video tags in the tree
 | 
			
		||||
    visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
 | 
			
		||||
      if (node.value && node.value.includes('<video') && typeof index === 'number') {
 | 
			
		||||
        const videoNode = processHTMLVideo(node, defaultOptions);
 | 
			
		||||
        if (videoNode) {
 | 
			
		||||
          videoNodes.push({ node, parent, index, replacement: videoNode });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Replace all found video nodes
 | 
			
		||||
    for (const { parent, index, replacement } of videoNodes.reverse()) {
 | 
			
		||||
      if (parent && parent.children && Array.isArray(parent.children)) {
 | 
			
		||||
        parent.children[index] = replacement;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function processVideoDirective(node: any, defaultOptions: VideoConfig['defaultOptions']): any | null {
 | 
			
		||||
  const attributes = node.attributes || {};
 | 
			
		||||
  const src = attributes.src;
 | 
			
		||||
  
 | 
			
		||||
  if (!src) {
 | 
			
		||||
    console.warn('[VIDEO PLUGIN] Video directive missing src attribute');
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Validate and normalize aspect ratio
 | 
			
		||||
  const aspectRatioValue = attributes.aspectRatio || attributes['aspect-ratio'] || defaultOptions?.aspectRatio;
 | 
			
		||||
  const validAspectRatios = ['16:9', '4:3', '1:1'] as const;
 | 
			
		||||
  const aspectRatio = validAspectRatios.includes(aspectRatioValue as any) 
 | 
			
		||||
    ? aspectRatioValue as '16:9' | '4:3' | '1:1'
 | 
			
		||||
    : '16:9';
 | 
			
		||||
 | 
			
		||||
  // Extract options from attributes
 | 
			
		||||
  const options = {
 | 
			
		||||
    controls: attributes.controls !== undefined ? attributes.controls !== 'false' : defaultOptions?.controls ?? true,
 | 
			
		||||
    autoplay: attributes.autoplay !== undefined ? attributes.autoplay !== 'false' : defaultOptions?.autoplay ?? false,
 | 
			
		||||
    muted: attributes.muted !== undefined ? attributes.muted !== 'false' : defaultOptions?.muted ?? false,
 | 
			
		||||
    loop: attributes.loop !== undefined ? attributes.loop !== 'false' : false,
 | 
			
		||||
    preload: (attributes.preload || 'metadata') as 'none' | 'metadata' | 'auto',
 | 
			
		||||
    aspectRatio,
 | 
			
		||||
    showMetadata: attributes.showMetadata !== undefined ? attributes.showMetadata !== 'false' : defaultOptions?.showMetadata ?? true
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const metadata = {
 | 
			
		||||
    title: attributes.title || extractTextContent(node),
 | 
			
		||||
    description: attributes.description || attributes.alt,
 | 
			
		||||
    poster: attributes.poster
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return createVideoElement(src, metadata, options);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function processImageAsVideo(node: any, defaultOptions: VideoConfig['defaultOptions']): any | null {
 | 
			
		||||
  const src = node.url;
 | 
			
		||||
  const metadata = {
 | 
			
		||||
    title: node.title || node.alt,
 | 
			
		||||
    description: node.alt
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Ensure aspectRatio is properly typed
 | 
			
		||||
  const options = {
 | 
			
		||||
    controls: defaultOptions?.controls ?? true,
 | 
			
		||||
    autoplay: defaultOptions?.autoplay ?? false,
 | 
			
		||||
    muted: defaultOptions?.muted ?? false,
 | 
			
		||||
    loop: false,
 | 
			
		||||
    preload: 'metadata' as const,
 | 
			
		||||
    aspectRatio: defaultOptions?.aspectRatio ?? '16:9' as const,
 | 
			
		||||
    showMetadata: defaultOptions?.showMetadata ?? true
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return createVideoElement(src, metadata, options);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function processLinkAsVideo(node: any, defaultOptions: VideoConfig['defaultOptions']): any | null {
 | 
			
		||||
  const src = node.url;
 | 
			
		||||
  const metadata = {
 | 
			
		||||
    title: node.title || extractTextContent(node),
 | 
			
		||||
    description: node.title
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Ensure aspectRatio is properly typed
 | 
			
		||||
  const options = {
 | 
			
		||||
    controls: defaultOptions?.controls ?? true,
 | 
			
		||||
    autoplay: defaultOptions?.autoplay ?? false,
 | 
			
		||||
    muted: defaultOptions?.muted ?? false,
 | 
			
		||||
    loop: false,
 | 
			
		||||
    preload: 'metadata' as const,
 | 
			
		||||
    aspectRatio: defaultOptions?.aspectRatio ?? '16:9' as const,
 | 
			
		||||
    showMetadata: defaultOptions?.showMetadata ?? true
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return createVideoElement(src, metadata, options);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function processHTMLVideo(node: any, defaultOptions: VideoConfig['defaultOptions']): any | null {
 | 
			
		||||
  try {
 | 
			
		||||
    // Parse HTML to extract video attributes
 | 
			
		||||
    const htmlContent = node.value;
 | 
			
		||||
    const srcMatch = htmlContent.match(/src=["']([^"']+)["']/);
 | 
			
		||||
    const titleMatch = htmlContent.match(/title=["']([^"']+)["']/);
 | 
			
		||||
    const posterMatch = htmlContent.match(/poster=["']([^"']+)["']/);
 | 
			
		||||
    
 | 
			
		||||
    if (!srcMatch) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const src = srcMatch[1];
 | 
			
		||||
    const metadata = {
 | 
			
		||||
      title: titleMatch?.[1],
 | 
			
		||||
      poster: posterMatch?.[1]
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const options = {
 | 
			
		||||
      controls: htmlContent.includes('controls'),
 | 
			
		||||
      autoplay: htmlContent.includes('autoplay'),
 | 
			
		||||
      muted: htmlContent.includes('muted'),
 | 
			
		||||
      loop: htmlContent.includes('loop'),
 | 
			
		||||
      preload: 'metadata' as const,
 | 
			
		||||
      aspectRatio: defaultOptions?.aspectRatio ?? '16:9' as const,
 | 
			
		||||
      showMetadata: defaultOptions?.showMetadata ?? true
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return createVideoElement(src, metadata, options);
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.warn('[VIDEO PLUGIN] Failed to process HTML video:', error);
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createVideoElement(src: string, metadata: any, options: {
 | 
			
		||||
  controls: boolean;
 | 
			
		||||
  autoplay: boolean;
 | 
			
		||||
  muted: boolean;
 | 
			
		||||
  loop: boolean;
 | 
			
		||||
  preload: 'none' | 'metadata' | 'auto';
 | 
			
		||||
  aspectRatio: '16:9' | '4:3' | '1:1';
 | 
			
		||||
  showMetadata: boolean;
 | 
			
		||||
}): any {
 | 
			
		||||
  // Generate a unique ID for this video
 | 
			
		||||
  const videoId = `video-${Math.random().toString(36).substr(2, 9)}`;
 | 
			
		||||
  
 | 
			
		||||
  // Create the video HTML structure as an HTML node
 | 
			
		||||
  const videoHTML = `
 | 
			
		||||
    <div class="video-container aspect-${options.aspectRatio}" data-video-id="${videoId}">
 | 
			
		||||
      <video 
 | 
			
		||||
        src="${escapeHtml(src)}"
 | 
			
		||||
        ${options.controls ? 'controls' : ''}
 | 
			
		||||
        ${options.autoplay ? 'autoplay' : ''}
 | 
			
		||||
        ${options.muted ? 'muted' : ''}
 | 
			
		||||
        ${options.loop ? 'loop' : ''}
 | 
			
		||||
        preload="${options.preload || 'metadata'}"
 | 
			
		||||
        ${metadata.poster ? `poster="${escapeHtml(metadata.poster)}"` : ''}
 | 
			
		||||
        ${metadata.title ? `data-video-title="${escapeHtml(metadata.title)}"` : ''}
 | 
			
		||||
        style="width: 100%; height: 100%;"
 | 
			
		||||
      >
 | 
			
		||||
        <p>Your browser does not support the video element.</p>
 | 
			
		||||
        <a href="${escapeHtml(src)}">${metadata.title || 'Download Video'}</a>
 | 
			
		||||
      </video>
 | 
			
		||||
      ${options.showMetadata && (metadata.title || metadata.description) ? `
 | 
			
		||||
        <div class="video-metadata">
 | 
			
		||||
          ${metadata.title ? `<div class="video-title">${escapeHtml(metadata.title)}</div>` : ''}
 | 
			
		||||
          ${metadata.description ? `<div class="video-description">${escapeHtml(metadata.description)}</div>` : ''}
 | 
			
		||||
        </div>
 | 
			
		||||
      ` : ''}
 | 
			
		||||
    </div>
 | 
			
		||||
  `.trim();
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    type: 'html',
 | 
			
		||||
    value: videoHTML
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function isVideoUrl(url: string): boolean {
 | 
			
		||||
  if (!url) return false;
 | 
			
		||||
  
 | 
			
		||||
  // Check for video file extensions
 | 
			
		||||
  const videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'm4v', '3gp'];
 | 
			
		||||
  const extension = url.split('.').pop()?.toLowerCase();
 | 
			
		||||
  if (extension && videoExtensions.includes(extension)) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Check for known video hosting patterns
 | 
			
		||||
  const videoPatterns = [
 | 
			
		||||
    /\/s\/[^\/]+.*\.(mp4|webm|ogg|mov|avi)/i, // Nextcloud shares
 | 
			
		||||
    /minio.*\.(mp4|webm|ogg|mov|avi)/i, // Minio
 | 
			
		||||
    /amazonaws\.com.*\.(mp4|webm|ogg|mov|avi)/i, // S3
 | 
			
		||||
    /\.s3\..*\.(mp4|webm|ogg|mov|avi)/i // S3
 | 
			
		||||
  ];
 | 
			
		||||
  
 | 
			
		||||
  return videoPatterns.some(pattern => pattern.test(url));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function extractTextContent(node: any): string {
 | 
			
		||||
  if (!node) return '';
 | 
			
		||||
  
 | 
			
		||||
  if (typeof node === 'string') return node;
 | 
			
		||||
  
 | 
			
		||||
  if (node.type === 'text') {
 | 
			
		||||
    return node.value || '';
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (node.children && Array.isArray(node.children)) {
 | 
			
		||||
    return node.children.map(extractTextContent).join('');
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return '';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function escapeHtml(unsafe: string): string {
 | 
			
		||||
  if (typeof unsafe !== 'string') return '';
 | 
			
		||||
  
 | 
			
		||||
  return unsafe
 | 
			
		||||
    .replace(/&/g, "&")
 | 
			
		||||
    .replace(/</g, "<")
 | 
			
		||||
    .replace(/>/g, ">")
 | 
			
		||||
    .replace(/"/g, """)
 | 
			
		||||
    .replace(/'/g, "'");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Enhanced video processing with async support
 | 
			
		||||
export async function processVideosInContent(content: string, config: VideoConfig = {}): Promise<string> {
 | 
			
		||||
  try {
 | 
			
		||||
    // Process video directives
 | 
			
		||||
    const videoDirectiveRegex = /:::video\{([^}]+)\}([\s\S]*?):::/g;
 | 
			
		||||
    let processedContent = content;
 | 
			
		||||
    let match;
 | 
			
		||||
    
 | 
			
		||||
    const replacements: Array<{ original: string; replacement: string }> = [];
 | 
			
		||||
    
 | 
			
		||||
    while ((match = videoDirectiveRegex.exec(content)) !== null) {
 | 
			
		||||
      try {
 | 
			
		||||
        const attributesStr = match[1];
 | 
			
		||||
        const bodyContent = match[2]?.trim() || '';
 | 
			
		||||
        
 | 
			
		||||
        // Parse attributes
 | 
			
		||||
        const attributes = parseDirectiveAttributes(attributesStr);
 | 
			
		||||
        
 | 
			
		||||
        if (!attributes.src) {
 | 
			
		||||
          console.warn('[VIDEO PLUGIN] Video directive missing src');
 | 
			
		||||
          continue;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const metadata = {
 | 
			
		||||
          title: attributes.title || bodyContent,
 | 
			
		||||
          description: attributes.description,
 | 
			
		||||
          poster: attributes.poster
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        const options = {
 | 
			
		||||
          controls: attributes.controls !== 'false',
 | 
			
		||||
          autoplay: attributes.autoplay === 'true',
 | 
			
		||||
          muted: attributes.muted === 'true',
 | 
			
		||||
          loop: attributes.loop === 'true',
 | 
			
		||||
          aspectRatio: (['16:9', '4:3', '1:1'].includes(attributes.aspectRatio) 
 | 
			
		||||
            ? attributes.aspectRatio 
 | 
			
		||||
            : '16:9') as '16:9' | '4:3' | '1:1',
 | 
			
		||||
          showMetadata: attributes.showMetadata !== 'false'
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        // Process with video processor for enhanced features
 | 
			
		||||
        if (config.enableAsync) {
 | 
			
		||||
          const processedVideo = await videoProcessor.processVideoUrl(attributes.src, metadata);
 | 
			
		||||
          const videoHTML = videoProcessor.generateVideoHTML(processedVideo, options);
 | 
			
		||||
          
 | 
			
		||||
          replacements.push({
 | 
			
		||||
            original: match[0],
 | 
			
		||||
            replacement: videoHTML
 | 
			
		||||
          });
 | 
			
		||||
        } else {
 | 
			
		||||
          // Simple replacement for sync processing
 | 
			
		||||
          const videoHTML = createSimpleVideoHTML(attributes.src, metadata, options);
 | 
			
		||||
          replacements.push({
 | 
			
		||||
            original: match[0],
 | 
			
		||||
            replacement: videoHTML
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('[VIDEO PLUGIN] Error processing video directive:', error);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Apply replacements
 | 
			
		||||
    for (const { original, replacement } of replacements) {
 | 
			
		||||
      processedContent = processedContent.replace(original, replacement);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return processedContent;
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('[VIDEO PLUGIN] Content processing failed:', error);
 | 
			
		||||
    return content; // Return original content on error
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function parseDirectiveAttributes(attributesStr: string): Record<string, string> {
 | 
			
		||||
  const attributes: Record<string, string> = {};
 | 
			
		||||
  
 | 
			
		||||
  // Match key="value" and key=value and standalone flags
 | 
			
		||||
  const attrRegex = /(\w+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s]+)))?/g;
 | 
			
		||||
  let match;
 | 
			
		||||
  
 | 
			
		||||
  while ((match = attrRegex.exec(attributesStr)) !== null) {
 | 
			
		||||
    const key = match[1];
 | 
			
		||||
    const value = match[2] || match[3] || match[4] || 'true';
 | 
			
		||||
    attributes[key] = value;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return attributes;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createSimpleVideoHTML(src: string, metadata: any, options: {
 | 
			
		||||
  controls: boolean;
 | 
			
		||||
  autoplay: boolean;
 | 
			
		||||
  muted: boolean;
 | 
			
		||||
  loop: boolean;
 | 
			
		||||
  aspectRatio: '16:9' | '4:3' | '1:1';
 | 
			
		||||
  showMetadata: boolean;
 | 
			
		||||
}): string {
 | 
			
		||||
  return `
 | 
			
		||||
    <div class="video-container aspect-${options.aspectRatio}">
 | 
			
		||||
      <video 
 | 
			
		||||
        src="${escapeHtml(src)}"
 | 
			
		||||
        ${options.controls ? 'controls' : ''}
 | 
			
		||||
        ${options.autoplay ? 'autoplay' : ''}
 | 
			
		||||
        ${options.muted ? 'muted' : ''}
 | 
			
		||||
        ${options.loop ? 'loop' : ''}
 | 
			
		||||
        ${metadata.poster ? `poster="${escapeHtml(metadata.poster)}"` : ''}
 | 
			
		||||
        style="width: 100%; height: 100%;"
 | 
			
		||||
      >
 | 
			
		||||
        <p>Your browser does not support the video element.</p>
 | 
			
		||||
      </video>
 | 
			
		||||
      ${options.showMetadata && metadata.title ? `
 | 
			
		||||
        <div class="video-metadata">
 | 
			
		||||
          <div class="video-title">${escapeHtml(metadata.title)}</div>
 | 
			
		||||
        </div>
 | 
			
		||||
      ` : ''}
 | 
			
		||||
    </div>
 | 
			
		||||
  `;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										696
									
								
								src/utils/videoUtils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										696
									
								
								src/utils/videoUtils.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,696 @@
 | 
			
		||||
// src/utils/videoUtils.ts - Production version with verbose logging
 | 
			
		||||
import { NextcloudUploader } from './nextcloud.js';
 | 
			
		||||
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
 | 
			
		||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 | 
			
		||||
import 'dotenv/config';
 | 
			
		||||
 | 
			
		||||
export interface VideoSource {
 | 
			
		||||
  type: 'nextcloud' | 's3' | 'minio' | 'direct' | 'local';
 | 
			
		||||
  url: string;
 | 
			
		||||
  originalUrl?: string;
 | 
			
		||||
  cached?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface VideoMetadata {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  duration?: number;
 | 
			
		||||
  format?: string;
 | 
			
		||||
  size?: number;
 | 
			
		||||
  width?: number;
 | 
			
		||||
  height?: number;
 | 
			
		||||
  poster?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ProcessedVideo {
 | 
			
		||||
  sources: VideoSource[];
 | 
			
		||||
  metadata: VideoMetadata;
 | 
			
		||||
  fallbackText: string;
 | 
			
		||||
  requiresAuth: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface VideoConfig {
 | 
			
		||||
  enableCaching: boolean;
 | 
			
		||||
  cacheDirectory: string;
 | 
			
		||||
  maxCacheSize: number; // in MB - only for emergency cleanup
 | 
			
		||||
  supportedFormats: string[];
 | 
			
		||||
  maxFileSize: number; // in MB
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class VideoProcessor {
 | 
			
		||||
  private config: VideoConfig;
 | 
			
		||||
  private nextcloudUploader: NextcloudUploader;
 | 
			
		||||
  private s3Client?: S3Client;
 | 
			
		||||
  
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.config = {
 | 
			
		||||
      enableCaching: process.env.VIDEO_CACHE_ENABLED === 'true',
 | 
			
		||||
      cacheDirectory: process.env.VIDEO_CACHE_DIR || './cache/videos',
 | 
			
		||||
      maxCacheSize: parseInt(process.env.VIDEO_CACHE_MAX_SIZE || '2000'),
 | 
			
		||||
      supportedFormats: ['mp4', 'webm', 'ogg', 'mov', 'avi'],
 | 
			
		||||
      maxFileSize: parseInt(process.env.VIDEO_MAX_SIZE || '200')
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (process.env.MINIO_URL && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY) {
 | 
			
		||||
      this.s3Client = new S3Client({
 | 
			
		||||
        endpoint: process.env.MINIO_URL, // e.g. http://127.0.0.1:9000
 | 
			
		||||
        region: 'us-east-1', // default
 | 
			
		||||
        forcePathStyle: true, // works for most MinIO setups
 | 
			
		||||
        credentials: {
 | 
			
		||||
          accessKeyId: process.env.MINIO_ACCESS_KEY,
 | 
			
		||||
          secretAccessKey: process.env.MINIO_SECRET_KEY
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] MinIO pre-signing enabled: ${process.env.MINIO_URL}`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Initialized with config:`, {
 | 
			
		||||
      caching: this.config.enableCaching,
 | 
			
		||||
      cacheDir: this.config.cacheDirectory,
 | 
			
		||||
      maxCacheSize: `${this.config.maxCacheSize}MB`,
 | 
			
		||||
      maxFileSize: `${this.config.maxFileSize}MB`,
 | 
			
		||||
      supportedFormats: this.config.supportedFormats
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    this.nextcloudUploader = new NextcloudUploader();
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Process a video URL and return optimized sources and metadata
 | 
			
		||||
   */
 | 
			
		||||
  async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Processing video URL: ${url}`);
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Provided metadata:`, metadata);
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const videoSource = this.identifyVideoSource(url);
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Identified source type: ${videoSource.type}`);
 | 
			
		||||
      
 | 
			
		||||
      const sources: VideoSource[] = [];
 | 
			
		||||
      
 | 
			
		||||
      // Handle different video sources
 | 
			
		||||
      switch (videoSource.type) {
 | 
			
		||||
        case 'nextcloud':
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Processing Nextcloud video...`);
 | 
			
		||||
          sources.push(...await this.processNextcloudVideo(url));
 | 
			
		||||
          break;
 | 
			
		||||
          
 | 
			
		||||
        case 's3':
 | 
			
		||||
        case 'minio':
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Processing ${videoSource.type} video...`);
 | 
			
		||||
          sources.push(...await this.processS3MinioVideo(url, videoSource.type));
 | 
			
		||||
          break;
 | 
			
		||||
          
 | 
			
		||||
        case 'direct':
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Processing direct video...`);
 | 
			
		||||
          sources.push(await this.processDirectVideo(url));
 | 
			
		||||
          break;
 | 
			
		||||
          
 | 
			
		||||
        case 'local':
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Processing local video...`);
 | 
			
		||||
          sources.push(await this.processLocalVideo(url));
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Generated ${sources.length} sources:`, sources.map(s => ({ type: s.type, cached: s.cached, url: s.url.substring(0, 50) + '...' })));
 | 
			
		||||
      
 | 
			
		||||
      // Extract or enhance metadata
 | 
			
		||||
      const enhancedMetadata = await this.enhanceMetadata(sources[0], metadata);
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Enhanced metadata:`, enhancedMetadata);
 | 
			
		||||
      
 | 
			
		||||
      const result = {
 | 
			
		||||
        sources,
 | 
			
		||||
        metadata: enhancedMetadata,
 | 
			
		||||
        fallbackText: this.generateFallbackText(enhancedMetadata),
 | 
			
		||||
        requiresAuth: this.requiresAuthentication(videoSource.type)
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Processing complete for ${url}:`, {
 | 
			
		||||
        sourcesCount: result.sources.length,
 | 
			
		||||
        hasCachedSource: result.sources.some(s => s.cached),
 | 
			
		||||
        requiresAuth: result.requiresAuth,
 | 
			
		||||
        fallbackText: result.fallbackText
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      return result;
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`[VIDEO PROCESSOR] Processing failed for ${url}:`, error);
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        sources: [],
 | 
			
		||||
        metadata: { ...metadata, title: metadata.title || 'Video unavailable' },
 | 
			
		||||
        fallbackText: `Video could not be loaded: ${error.message}`,
 | 
			
		||||
        requiresAuth: false
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Identifying source for URL: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    // Nextcloud share links
 | 
			
		||||
    if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Detected Nextcloud share link`);
 | 
			
		||||
      return { type: 'nextcloud', url };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // S3 URLs
 | 
			
		||||
    if (url.includes('amazonaws.com') || url.includes('.s3.')) {
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Detected S3 URL`);
 | 
			
		||||
      return { type: 's3', url };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Minio URLs (common patterns)
 | 
			
		||||
    if (url.includes('minio') || url.match(/:\d+\/[^\/]+\/.*\.(mp4|webm|ogg|mov|avi)/i)) {
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Detected Minio URL`);
 | 
			
		||||
      return { type: 'minio', url };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Local files (relative paths)
 | 
			
		||||
    if (url.startsWith('/') && !url.startsWith('http')) {
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Detected local file path`);
 | 
			
		||||
      return { type: 'local', url };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Direct HTTP(S) URLs
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Detected direct HTTP URL`);
 | 
			
		||||
    return { type: 'direct', url };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Processing Nextcloud video: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      // Extract share token from Nextcloud URL
 | 
			
		||||
      const shareMatch = url.match(/\/s\/([^\/\?]+)/);
 | 
			
		||||
      if (!shareMatch) {
 | 
			
		||||
        throw new Error('Invalid Nextcloud share URL format');
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const shareToken = shareMatch[1];
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Extracted share token: ${shareToken}`);
 | 
			
		||||
      
 | 
			
		||||
      // Try to get direct download URL
 | 
			
		||||
      const directUrl = await this.getNextcloudDirectUrl(url, shareToken);
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Direct download URL: ${directUrl || 'Not available'}`);
 | 
			
		||||
      
 | 
			
		||||
      const sources: VideoSource[] = [];
 | 
			
		||||
      
 | 
			
		||||
      // Always try to cache Nextcloud videos for performance
 | 
			
		||||
      if (this.config.enableCaching && directUrl) {
 | 
			
		||||
        console.log(`[VIDEO PROCESSOR] Attempting to cache Nextcloud video...`);
 | 
			
		||||
        const cachedSource = await this.cacheVideo(directUrl, shareToken);
 | 
			
		||||
        if (cachedSource) {
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Successfully created cached source: ${cachedSource.url}`);
 | 
			
		||||
          sources.push(cachedSource); // Prioritize cached version
 | 
			
		||||
        } else {
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Caching failed or skipped for Nextcloud video`);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        console.log(`[VIDEO PROCESSOR] Caching disabled or no direct URL - using original URL`);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Add original as fallback
 | 
			
		||||
      sources.push({
 | 
			
		||||
        type: 'nextcloud',
 | 
			
		||||
        url: directUrl || url,
 | 
			
		||||
        originalUrl: url,
 | 
			
		||||
        cached: false
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Nextcloud processing complete. Sources: ${sources.length}`);
 | 
			
		||||
      return sources;
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[VIDEO PROCESSOR] Nextcloud processing failed:', error);
 | 
			
		||||
      return [{
 | 
			
		||||
        type: 'nextcloud',
 | 
			
		||||
        url: url,
 | 
			
		||||
        cached: false
 | 
			
		||||
      }];
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async getNextcloudDirectUrl(shareUrl: string, shareToken: string): Promise<string | null> {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Getting direct URL for share token: ${shareToken}`);
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const baseUrl = shareUrl.split('/s/')[0];
 | 
			
		||||
      const directUrl = `${baseUrl}/s/${shareToken}/download`;
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Testing direct URL: ${directUrl}`);
 | 
			
		||||
      
 | 
			
		||||
      // Verify the URL is accessible
 | 
			
		||||
      const response = await fetch(directUrl, { method: 'HEAD' });
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Direct URL test response: ${response.status} ${response.statusText}`);
 | 
			
		||||
      
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        const contentType = response.headers.get('content-type');
 | 
			
		||||
        const contentLength = response.headers.get('content-length');
 | 
			
		||||
        console.log(`[VIDEO PROCESSOR] Direct URL validated. Content-Type: ${contentType}, Size: ${contentLength} bytes`);
 | 
			
		||||
        return directUrl;
 | 
			
		||||
      } else {
 | 
			
		||||
        console.warn(`[VIDEO PROCESSOR] Direct URL not accessible: ${response.status}`);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return null;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.warn('[VIDEO PROCESSOR] Direct URL extraction failed:', error);
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Replace processS3MinioVideo() with:
 | 
			
		||||
  private async processS3MinioVideo(url: string, type: 's3' | 'minio'): Promise<VideoSource[]> {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Processing ${type} video: ${url}`);
 | 
			
		||||
 | 
			
		||||
    if (type === 'minio' && this.s3Client) {
 | 
			
		||||
      const parsed = this.normalizeMinioUrl(url);
 | 
			
		||||
      if (parsed) {
 | 
			
		||||
        try {
 | 
			
		||||
          const cmd = new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key });
 | 
			
		||||
          const signed = await getSignedUrl(this.s3Client, cmd, { expiresIn: 3600 }); // 1h
 | 
			
		||||
          return [{ type: 'minio', url: signed, originalUrl: url, cached: false }];
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          console.warn('[VIDEO PROCESSOR] MinIO pre-sign failed:', e);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return [{ type, url, cached: false }];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private normalizeMinioUrl(inputUrl: string): { bucket: string; key: string } | null {
 | 
			
		||||
    try {
 | 
			
		||||
      const u = new URL(inputUrl);
 | 
			
		||||
      // Convert console URL to bucket/key
 | 
			
		||||
      if (u.pathname.startsWith('/browser/')) {
 | 
			
		||||
        const parts = u.pathname.replace('/browser/', '').split('/');
 | 
			
		||||
        return { bucket: parts[0], key: parts.slice(1).join('/') };
 | 
			
		||||
      }
 | 
			
		||||
      // Path-style
 | 
			
		||||
      const parts = u.pathname.replace(/^\/+/, '').split('/');
 | 
			
		||||
      if (parts.length >= 2) return { bucket: parts[0], key: parts.slice(1).join('/') };
 | 
			
		||||
      return null;
 | 
			
		||||
    } catch {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  private async processDirectVideo(url: string): Promise<VideoSource> {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Processing direct video: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    return {
 | 
			
		||||
      type: 'direct',
 | 
			
		||||
      url,
 | 
			
		||||
      cached: false
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async processLocalVideo(url: string): Promise<VideoSource> {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Processing local video: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    return {
 | 
			
		||||
      type: 'local',
 | 
			
		||||
      url,
 | 
			
		||||
      cached: true
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async cacheVideo(sourceUrl: string, identifier: string): Promise<VideoSource | null> {
 | 
			
		||||
    if (!this.config.enableCaching) {
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Caching disabled, skipping cache for: ${identifier}`);
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Starting cache process for: ${identifier}`);
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Source URL: ${sourceUrl}`);
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const fs = await import('fs/promises');
 | 
			
		||||
      const path = await import('path');
 | 
			
		||||
      const crypto = await import('crypto');
 | 
			
		||||
      
 | 
			
		||||
      // Generate cache filename
 | 
			
		||||
      const urlHash = crypto.createHash('sha256').update(sourceUrl).digest('hex').substring(0, 16);
 | 
			
		||||
      const extension = path.extname(new URL(sourceUrl).pathname) || '.mp4';
 | 
			
		||||
      const cacheFilename = `${identifier}_${urlHash}${extension}`;
 | 
			
		||||
      const cachePath = path.join(this.config.cacheDirectory, cacheFilename);
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Cache filename: ${cacheFilename}`);
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Cache path: ${cachePath}`);
 | 
			
		||||
      
 | 
			
		||||
      // Check if already cached (no expiration check - cache indefinitely)
 | 
			
		||||
      try {
 | 
			
		||||
        const stat = await fs.stat(cachePath);
 | 
			
		||||
        const sizeMB = Math.round(stat.size / 1024 / 1024);
 | 
			
		||||
        console.log(`[VIDEO PROCESSOR] Found existing cached video: ${cacheFilename} (${sizeMB}MB, created: ${stat.birthtime || stat.ctime})`);
 | 
			
		||||
        
 | 
			
		||||
        return {
 | 
			
		||||
          type: 'local',
 | 
			
		||||
          url: `/api/video/cached/${cacheFilename}`,
 | 
			
		||||
          originalUrl: sourceUrl,
 | 
			
		||||
          cached: true
 | 
			
		||||
        };
 | 
			
		||||
      } catch (statError) {
 | 
			
		||||
        console.log(`[VIDEO PROCESSOR] No existing cache found for ${cacheFilename}, proceeding with download`);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Ensure cache directory exists
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Ensuring cache directory exists: ${this.config.cacheDirectory}`);
 | 
			
		||||
      await fs.mkdir(this.config.cacheDirectory, { recursive: true });
 | 
			
		||||
      
 | 
			
		||||
      // Download and cache the video
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Starting download from: ${sourceUrl}`);
 | 
			
		||||
      const downloadStartTime = Date.now();
 | 
			
		||||
      
 | 
			
		||||
      const response = await fetch(sourceUrl);
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Download response: ${response.status} ${response.statusText}`);
 | 
			
		||||
      
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Check file size
 | 
			
		||||
      const contentLength = parseInt(response.headers.get('content-length') || '0');
 | 
			
		||||
      const contentLengthMB = Math.round(contentLength / 1024 / 1024);
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Content-Length: ${contentLength} bytes (${contentLengthMB}MB)`);
 | 
			
		||||
      
 | 
			
		||||
      if (contentLength > this.config.maxFileSize * 1024 * 1024) {
 | 
			
		||||
        console.warn(`[VIDEO PROCESSOR] Video too large for caching: ${contentLengthMB}MB > ${this.config.maxFileSize}MB - will use direct streaming`);
 | 
			
		||||
        return null; // Don't cache, but don't fail either
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Stream to file
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Downloading video content...`);
 | 
			
		||||
      const buffer = await response.arrayBuffer();
 | 
			
		||||
      const downloadDuration = Date.now() - downloadStartTime;
 | 
			
		||||
      const actualSizeMB = Math.round(buffer.byteLength / 1024 / 1024);
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Download complete in ${downloadDuration}ms. Actual size: ${actualSizeMB}MB`);
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Writing to cache file: ${cachePath}`);
 | 
			
		||||
      await fs.writeFile(cachePath, new Uint8Array(buffer));
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Successfully cached: ${cacheFilename} (${actualSizeMB}MB)`);
 | 
			
		||||
      
 | 
			
		||||
      // Only clean up if we're getting critically low on space
 | 
			
		||||
      await this.emergencyCleanupIfNeeded();
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        type: 'local',
 | 
			
		||||
        url: `/api/video/cached/${cacheFilename}`,
 | 
			
		||||
        originalUrl: sourceUrl,
 | 
			
		||||
        cached: true
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`[VIDEO PROCESSOR] Caching failed for ${identifier}:`, error);
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async emergencyCleanupIfNeeded(): Promise<void> {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Checking if emergency cleanup is needed...`);
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const fs = await import('fs/promises');
 | 
			
		||||
      const path = await import('path');
 | 
			
		||||
      
 | 
			
		||||
      const files = await fs.readdir(this.config.cacheDirectory);
 | 
			
		||||
      const videoFiles = files.filter(f => this.config.supportedFormats.some(fmt => f.toLowerCase().endsWith(`.${fmt}`)));
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Found ${videoFiles.length} cached video files`);
 | 
			
		||||
      
 | 
			
		||||
      const fileStats = await Promise.all(
 | 
			
		||||
        videoFiles.map(async (file) => {
 | 
			
		||||
          const filePath = path.join(this.config.cacheDirectory, file);
 | 
			
		||||
          const stat = await fs.stat(filePath);
 | 
			
		||||
          return { file, filePath, stat };
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
      
 | 
			
		||||
      // Calculate total cache size
 | 
			
		||||
      let totalSize = fileStats.reduce((sum, { stat }) => sum + stat.size, 0);
 | 
			
		||||
      const totalSizeMB = Math.round(totalSize / 1024 / 1024);
 | 
			
		||||
      const maxBytes = this.config.maxCacheSize * 1024 * 1024;
 | 
			
		||||
      const maxSizeMB = this.config.maxCacheSize;
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Cache usage: ${totalSizeMB}MB / ${maxSizeMB}MB (${Math.round((totalSize / maxBytes) * 100)}%)`);
 | 
			
		||||
      
 | 
			
		||||
      // Only clean up if we're over the limit
 | 
			
		||||
      if (totalSize > maxBytes) {
 | 
			
		||||
        console.warn(`[VIDEO PROCESSOR] Cache size (${totalSizeMB}MB) exceeds limit (${maxSizeMB}MB). Starting emergency cleanup...`);
 | 
			
		||||
        
 | 
			
		||||
        // Sort by access time (least recently accessed first)
 | 
			
		||||
        fileStats.sort((a, b) => a.stat.atime.getTime() - b.stat.atime.getTime());
 | 
			
		||||
        
 | 
			
		||||
        const targetSize = maxBytes * 0.8; // Clean to 80% of limit
 | 
			
		||||
        let removedCount = 0;
 | 
			
		||||
        let removedSize = 0;
 | 
			
		||||
        
 | 
			
		||||
        // Remove oldest files until we're under the target
 | 
			
		||||
        for (const { file, filePath, stat } of fileStats) {
 | 
			
		||||
          if (totalSize <= targetSize) break;
 | 
			
		||||
          
 | 
			
		||||
          const fileSizeMB = Math.round(stat.size / 1024 / 1024);
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Emergency cleanup removing: ${file} (${fileSizeMB}MB, last accessed: ${stat.atime})`);
 | 
			
		||||
          
 | 
			
		||||
          await fs.unlink(filePath);
 | 
			
		||||
          totalSize -= stat.size;
 | 
			
		||||
          removedSize += stat.size;
 | 
			
		||||
          removedCount++;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const finalSizeMB = Math.round(totalSize / 1024 / 1024);
 | 
			
		||||
        const removedSizeMB = Math.round(removedSize / 1024 / 1024);
 | 
			
		||||
        
 | 
			
		||||
        console.log(`[VIDEO PROCESSOR] Emergency cleanup complete. Removed ${removedCount} files (${removedSizeMB}MB). New cache size: ${finalSizeMB}MB`);
 | 
			
		||||
      } else {
 | 
			
		||||
        console.log(`[VIDEO PROCESSOR] Cache size within limits, no cleanup needed`);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[VIDEO PROCESSOR] Emergency cleanup failed:', error);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async enhanceMetadata(source: VideoSource, providedMetadata: Partial<VideoMetadata>): Promise<VideoMetadata> {
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Enhancing metadata for source type: ${source.type}`);
 | 
			
		||||
    
 | 
			
		||||
    const metadata: VideoMetadata = { ...providedMetadata };
 | 
			
		||||
    
 | 
			
		||||
    // Try to extract metadata from file if possible
 | 
			
		||||
    if (source.type === 'local' && source.cached) {
 | 
			
		||||
      try {
 | 
			
		||||
        const path = await import('path');
 | 
			
		||||
        const ext = path.extname(source.url).toLowerCase().replace('.', '');
 | 
			
		||||
        
 | 
			
		||||
        if (!metadata.format) {
 | 
			
		||||
          metadata.format = ext;
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Detected format from extension: ${ext}`);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if (!metadata.title) {
 | 
			
		||||
          metadata.title = path.basename(source.url, path.extname(source.url));
 | 
			
		||||
          console.log(`[VIDEO PROCESSOR] Generated title from filename: ${metadata.title}`);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.warn('[VIDEO PROCESSOR] Metadata extraction failed:', error);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Set defaults
 | 
			
		||||
    if (!metadata.title) {
 | 
			
		||||
      metadata.title = 'Embedded Video';
 | 
			
		||||
      console.log(`[VIDEO PROCESSOR] Using default title: ${metadata.title}`);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Final metadata:`, metadata);
 | 
			
		||||
    return metadata;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private generateFallbackText(metadata: VideoMetadata): string {
 | 
			
		||||
    let fallback = `[Video: ${metadata.title}]`;
 | 
			
		||||
    
 | 
			
		||||
    if (metadata.description) {
 | 
			
		||||
      fallback += ` - ${metadata.description}`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (metadata.duration) {
 | 
			
		||||
      fallback += ` (${Math.floor(metadata.duration / 60)}:${(metadata.duration % 60).toString().padStart(2, '0')})`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Generated fallback text: ${fallback}`);
 | 
			
		||||
    return fallback;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private requiresAuthentication(sourceType: VideoSource['type']): boolean {
 | 
			
		||||
    const requiresAuth = sourceType === 'nextcloud';
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Authentication required for ${sourceType}: ${requiresAuth}`);
 | 
			
		||||
    return requiresAuth;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /**
 | 
			
		||||
   * Generate HTML for video embedding in markdown
 | 
			
		||||
   */
 | 
			
		||||
  generateVideoHTML(processedVideo: ProcessedVideo, options: {
 | 
			
		||||
    controls?: boolean;
 | 
			
		||||
    autoplay?: boolean;
 | 
			
		||||
    muted?: boolean;
 | 
			
		||||
    loop?: boolean;
 | 
			
		||||
    preload?: 'none' | 'metadata' | 'auto';
 | 
			
		||||
    width?: string;
 | 
			
		||||
    height?: string;
 | 
			
		||||
    aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
    showMetadata?: boolean;
 | 
			
		||||
  } = {}): string {
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Generating HTML for video with ${processedVideo.sources.length} sources`);
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] HTML options:`, options);
 | 
			
		||||
    
 | 
			
		||||
    const {
 | 
			
		||||
      controls = true,
 | 
			
		||||
      autoplay = false,
 | 
			
		||||
      muted = false,
 | 
			
		||||
      loop = false,
 | 
			
		||||
      preload = 'metadata',
 | 
			
		||||
      aspectRatio = '16:9',
 | 
			
		||||
      showMetadata = true
 | 
			
		||||
    } = options;
 | 
			
		||||
    
 | 
			
		||||
    if (processedVideo.sources.length === 0) {
 | 
			
		||||
      console.warn(`[VIDEO PROCESSOR] No sources available, generating error HTML`);
 | 
			
		||||
      return `
 | 
			
		||||
        <div class="video-container aspect-${aspectRatio}">
 | 
			
		||||
          <div class="video-error">
 | 
			
		||||
            <div class="error-icon">⚠️</div>
 | 
			
		||||
            <div>${processedVideo.fallbackText}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      `;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const primarySource = processedVideo.sources[0];
 | 
			
		||||
    const { metadata } = processedVideo;
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Primary source: ${primarySource.type} (cached: ${primarySource.cached})`);
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Primary source URL: ${primarySource.url}`);
 | 
			
		||||
    
 | 
			
		||||
    let videoAttributes = [
 | 
			
		||||
      controls ? 'controls' : '',
 | 
			
		||||
      autoplay ? 'autoplay' : '',
 | 
			
		||||
      muted ? 'muted' : '',
 | 
			
		||||
      loop ? 'loop' : '',
 | 
			
		||||
      `preload="${preload}"`,
 | 
			
		||||
      metadata.poster ? `poster="${metadata.poster}"` : '',
 | 
			
		||||
      `data-video-title="${metadata.title || 'Embedded Video'}"`
 | 
			
		||||
    ].filter(Boolean).join(' ');
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Video attributes: ${videoAttributes}`);
 | 
			
		||||
    
 | 
			
		||||
    const sourceTags = processedVideo.sources
 | 
			
		||||
      .map(source => {
 | 
			
		||||
        const mimeType = this.getMimeType(source.url);
 | 
			
		||||
        console.log(`[VIDEO PROCESSOR] Source tag: ${source.url} (${mimeType})`);
 | 
			
		||||
        return `<source src="${source.url}" type="${mimeType}">`;
 | 
			
		||||
      })
 | 
			
		||||
      .join('\n      ');
 | 
			
		||||
    
 | 
			
		||||
    const metadataHTML = showMetadata && (metadata.title || metadata.duration || metadata.format) ? `
 | 
			
		||||
      <div class="video-metadata">
 | 
			
		||||
        ${metadata.title ? `<div class="video-title">${metadata.title}</div>` : ''}
 | 
			
		||||
        <div class="video-info">
 | 
			
		||||
          ${metadata.duration ? `<div class="video-duration">⏱️ ${this.formatDuration(metadata.duration)}</div>` : ''}
 | 
			
		||||
          ${metadata.format ? `<div class="video-format">🎥 ${metadata.format.toUpperCase()}</div>` : ''}
 | 
			
		||||
          ${metadata.size ? `<div class="video-size">💾 ${this.formatFileSize(metadata.size)}</div>` : ''}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    ` : '';
 | 
			
		||||
    
 | 
			
		||||
    const finalHTML = `
 | 
			
		||||
      <div class="video-container aspect-${aspectRatio}">
 | 
			
		||||
        <video ${videoAttributes}>
 | 
			
		||||
          ${sourceTags}
 | 
			
		||||
          <p>Your browser does not support the video element. ${processedVideo.fallbackText}</p>
 | 
			
		||||
        </video>
 | 
			
		||||
        ${metadataHTML}
 | 
			
		||||
      </div>
 | 
			
		||||
    `.trim();
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] Generated HTML (${finalHTML.length} chars) with metadata: ${!!metadataHTML}`);
 | 
			
		||||
    
 | 
			
		||||
    return finalHTML;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private getMimeType(url: string): string {
 | 
			
		||||
    const extension = url.split('.').pop()?.toLowerCase();
 | 
			
		||||
    const mimeTypes: Record<string, string> = {
 | 
			
		||||
      'mp4': 'video/mp4',
 | 
			
		||||
      'webm': 'video/webm',
 | 
			
		||||
      'ogg': 'video/ogg',
 | 
			
		||||
      'mov': 'video/quicktime',
 | 
			
		||||
      'avi': 'video/x-msvideo',
 | 
			
		||||
      'm4v': 'video/m4v'
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    const mimeType = mimeTypes[extension || ''] || 'video/mp4';
 | 
			
		||||
    console.log(`[VIDEO PROCESSOR] MIME type for extension '${extension}': ${mimeType}`);
 | 
			
		||||
    
 | 
			
		||||
    return mimeType;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private formatDuration(seconds: number): string {
 | 
			
		||||
    const hours = Math.floor(seconds / 3600);
 | 
			
		||||
    const minutes = Math.floor((seconds % 3600) / 60);
 | 
			
		||||
    const remainingSeconds = Math.floor(seconds % 60);
 | 
			
		||||
    
 | 
			
		||||
    if (hours > 0) {
 | 
			
		||||
      return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private formatFileSize(bytes: number): string {
 | 
			
		||||
    if (bytes < 1024) return `${bytes} B`;
 | 
			
		||||
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
 | 
			
		||||
    if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
 | 
			
		||||
    return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Export singleton instance
 | 
			
		||||
export const videoProcessor = new VideoProcessor();
 | 
			
		||||
 | 
			
		||||
// Production utility functions for markdown integration
 | 
			
		||||
export async function processVideoEmbed(url: string, metadata?: Partial<VideoMetadata>, options?: {
 | 
			
		||||
  controls?: boolean;
 | 
			
		||||
  autoplay?: boolean;
 | 
			
		||||
  muted?: boolean;
 | 
			
		||||
  loop?: boolean;
 | 
			
		||||
  preload?: 'none' | 'metadata' | 'auto';
 | 
			
		||||
  aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
  showMetadata?: boolean;
 | 
			
		||||
}): Promise<string> {
 | 
			
		||||
  console.log(`[VIDEO EMBED] Processing embed for: ${url}`);
 | 
			
		||||
  
 | 
			
		||||
  const processedVideo = await videoProcessor.processVideoUrl(url, metadata);
 | 
			
		||||
  const html = videoProcessor.generateVideoHTML(processedVideo, options);
 | 
			
		||||
  
 | 
			
		||||
  console.log(`[VIDEO EMBED] Generated HTML embed for: ${url}`);
 | 
			
		||||
  return html;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isVideoUrl(url: string): boolean {
 | 
			
		||||
  const videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi'];
 | 
			
		||||
  const extension = url.split('.').pop()?.toLowerCase();
 | 
			
		||||
  const isVideo = videoExtensions.includes(extension || '');
 | 
			
		||||
  
 | 
			
		||||
  console.log(`[VIDEO EMBED] URL ${url} is video: ${isVideo}`);
 | 
			
		||||
  return isVideo;
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user