simplify video stuff

This commit is contained in:
overcuriousity 2025-08-12 22:13:14 +02:00
parent b291492e2d
commit 27b94edcfa
8 changed files with 202 additions and 629 deletions

View File

@ -1,5 +1,5 @@
---
// src/components/Video.astro - SIMPLE wrapper component
// src/components/Video.astro - SIMPLE responsive video component
export interface Props {
src: string;
title?: string;
@ -8,6 +8,7 @@ export interface Props {
muted?: boolean;
loop?: boolean;
aspectRatio?: '16:9' | '4:3' | '1:1';
preload?: 'none' | 'metadata' | 'auto';
}
const {
@ -17,17 +18,21 @@ const {
autoplay = false,
muted = false,
loop = false,
aspectRatio = '16:9'
aspectRatio = '16:9',
preload = 'metadata'
} = Astro.props;
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
---
<div class={`video-container aspect-${aspectRatio}`}>
<div class={`video-container ${aspectClass}`}>
<video
src={src}
controls={controls}
autoplay={autoplay}
muted={muted}
loop={loop}
preload={preload}
style="width: 100%; height: 100%;"
data-video-title={title}
>

View File

@ -17,10 +17,8 @@ sections:
advanced_topics: false
review_status: "published"
---
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls title="Nextcloud Demo"></video>
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls title="Training Video"></video>
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls></video>
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!

View File

@ -18,6 +18,8 @@ sections:
advanced_topics: true
review_status: "published"
---
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!

View File

@ -352,6 +352,37 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
};
initAIButton();
});
document.addEventListener('DOMContentLoaded', () => {
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox') ||
navigator.userAgent.toLowerCase().includes('librewolf');
if (isFirefox) {
console.log('[VIDEO] Firefox detected - setting up error recovery');
document.querySelectorAll('video').forEach(video => {
let errorCount = 0;
video.addEventListener('error', () => {
errorCount++;
console.log(`[VIDEO] Error ${errorCount} in Firefox for: ${video.getAttribute('data-video-title')}`);
// Only try once to avoid infinite loops
if (errorCount === 1 && video.src.includes('/download')) {
console.log('[VIDEO] Trying /preview URL for Firefox compatibility');
video.src = video.src.replace('/download', '/preview');
video.load();
} else if (errorCount === 1) {
console.log('[VIDEO] Video failed to load in Firefox');
}
});
video.addEventListener('loadedmetadata', () => {
const title = video.getAttribute('data-video-title') || 'Video';
console.log(`[VIDEO] Successfully loaded: ${title}`);
});
});
}
});
</script>
</head>
<body>

View File

@ -1,142 +0,0 @@
// 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;
}

View File

@ -1,43 +0,0 @@
// 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}`);
}
};

View File

@ -1,58 +1,95 @@
// src/utils/remarkVideoPlugin.ts - MINIMAL wrapper only
// src/utils/remarkVideoPlugin.ts - Simple, working approach
import { visit } from 'unist-util-visit';
import type { Plugin } from 'unified';
import type { Root } from 'hast';
/**
* MINIMAL plugin - just wraps <video> tags in responsive containers
* Simple video plugin - just makes videos responsive and adds /download to bare Nextcloud URLs
* No CORS complications, no crossorigin attributes
*/
export const remarkVideoPlugin: Plugin<[], Root> = () => {
return (tree: Root) => {
// Find HTML nodes containing <video> tags
visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
if (node.value && node.value.includes('<video') && typeof index === 'number') {
// Extract video attributes
const srcMatch = node.value.match(/src=["']([^"']+)["']/);
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
if (srcMatch) {
const src = srcMatch[1];
const originalSrc = srcMatch[1];
const title = titleMatch?.[1] || 'Video';
// Check for existing attributes
// Smart URL processing - add /download to bare Nextcloud URLs
const finalSrc = processNextcloudUrl(originalSrc);
// Check for existing attributes to preserve them
const hasControls = node.value.includes('controls');
const hasAutoplay = node.value.includes('autoplay');
const hasMuted = node.value.includes('muted');
const hasLoop = node.value.includes('loop');
const hasPreload = node.value.match(/preload=["']([^"']+)["']/);
// Create wrapped HTML
const wrappedHTML = `
<div class="video-container aspect-16:9">
// Create simple, working video HTML - NO crossorigin attribute
const enhancedHTML = `
<div class="video-container aspect-16-9">
<video
src="${escapeHtml(src)}"
src="${escapeHtml(finalSrc)}"
${hasControls ? 'controls' : ''}
${hasAutoplay ? 'autoplay' : ''}
${hasMuted ? 'muted' : ''}
${hasLoop ? 'loop' : ''}
${hasPreload ? `preload="${hasPreload[1]}"` : 'preload="metadata"'}
style="width: 100%; height: 100%;"
data-video-title="${escapeHtml(title)}"
data-original-src="${escapeHtml(originalSrc)}"
>
<p>Your browser does not support the video element.</p>
</video>
<div class="video-metadata">
<div class="video-title">${escapeHtml(title)}</div>
</div>
${title !== 'Video' ? `
<div class="video-metadata">
<div class="video-title">${escapeHtml(title)}</div>
</div>
` : ''}
</div>
`.trim();
// Replace the node
parent.children[index] = { type: 'html', value: wrappedHTML };
parent.children[index] = { type: 'html', value: enhancedHTML };
console.log(`[VIDEO] Processed: ${title}`);
console.log(`[VIDEO] Final URL: ${finalSrc}`);
}
}
});
};
};
/**
* Simple URL processing - just add /download to bare Nextcloud URLs if needed
*/
function processNextcloudUrl(originalUrl: string): string {
// If it's a bare Nextcloud share URL, add /download
if (isNextcloudShareUrl(originalUrl) && !originalUrl.includes('/download')) {
const downloadUrl = `${originalUrl}/download`;
console.log(`[VIDEO] Auto-added /download: ${originalUrl}${downloadUrl}`);
return downloadUrl;
}
// Otherwise, use the URL as-is
return originalUrl;
}
/**
* Check if URL is a Nextcloud share URL (bare or with /download)
* Format: https://cloud.cc24.dev/s/TOKEN
*/
function isNextcloudShareUrl(url: string): boolean {
const pattern = /\/s\/[a-zA-Z0-9]+/;
return pattern.test(url) && (url.includes('nextcloud') || url.includes('cloud.'));
}
function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';

View File

@ -1,449 +1,134 @@
// src/utils/videoUtils.ts - NEXTCLOUD ONLY
import { NextcloudUploader } from './nextcloud.js';
// src/utils/videoUtils.ts - SIMPLIFIED - Basic utilities only
import 'dotenv/config';
export interface VideoSource {
type: 'nextcloud' | 'cdn' | 'local';
url: string;
originalUrl?: string;
cached?: boolean;
}
/**
* Simple video utilities for basic video support
* No caching, no complex processing, no authentication
*/
export interface VideoMetadata {
export interface SimpleVideoMetadata {
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;
/**
* Get video MIME type from file extension
*/
export function getVideoMimeType(url: string): string {
let extension: string | undefined;
try {
const pathname = new URL(url).pathname;
extension = pathname.split('.').pop()?.toLowerCase();
} catch {
extension = url.split('?')[0].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',
mkv: 'video/x-matroska',
flv: 'video/x-flv'
};
return (extension && mimeTypes[extension]) || 'video/mp4';
}
interface VideoConfig {
enableCaching: boolean;
cacheDirectory: string;
maxCacheSize: number;
supportedFormats: string[];
maxFileSize: number;
/**
* Format duration in MM:SS or HH:MM:SS format
*/
export function 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')}`;
}
export class VideoProcessor {
private config: VideoConfig;
private nextcloudUploader: NextcloudUploader;
/**
* Format file size in human readable format
*/
export function 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`;
}
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')
};
/**
* Escape HTML for safe output
*/
export function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
this.nextcloudUploader = new NextcloudUploader();
console.log('[VIDEO] Nextcloud-only video processor initialized');
}
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
console.log(`[VIDEO] Processing: ${url}`);
try {
const videoSource = this.identifyVideoSource(url);
console.log(`[VIDEO] Source type: ${videoSource.type}`);
const sources: VideoSource[] = [];
switch (videoSource.type) {
case 'nextcloud':
sources.push(...await this.processNextcloudVideo(url));
break;
case 'cdn':
sources.push(await this.processCdnVideo(url));
break;
case 'local':
sources.push(await this.processLocalVideo(url));
break;
}
const enhancedMetadata = await this.enhanceMetadata(sources[0], metadata);
return {
sources,
metadata: enhancedMetadata,
fallbackText: this.generateFallbackText(enhancedMetadata),
requiresAuth: this.requiresAuthentication(videoSource.type)
};
} catch (error) {
console.error(`[VIDEO] Processing failed: ${error.message}`);
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] Identifying source for: ${url}`);
// Check for Nextcloud patterns
if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
console.log(`[VIDEO] Detected Nextcloud URL`);
return { type: 'nextcloud', url };
}
// Local files
if (url.startsWith('/') && !url.startsWith('http')) {
console.log(`[VIDEO] Detected local file`);
return { type: 'local', url };
}
// Everything else is a CDN
console.log(`[VIDEO] Detected CDN URL`);
return { type: 'cdn', url };
}
private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
console.log(`[VIDEO] Processing Nextcloud: ${url}`);
try {
const shareMatch = url.match(/\/s\/([^\/\?]+)/);
if (!shareMatch) {
throw new Error('Invalid Nextcloud share URL format');
}
const shareToken = shareMatch[1];
const directUrl = await this.getNextcloudDirectUrl(url, shareToken);
const sources: VideoSource[] = [];
// Try caching if enabled
if (this.config.enableCaching && directUrl) {
const cachedSource = await this.cacheVideo(directUrl, shareToken);
if (cachedSource) {
sources.push(cachedSource);
}
}
// Always include direct source as fallback
sources.push({
type: 'nextcloud',
url: directUrl || url,
originalUrl: url,
cached: false
});
return sources;
} catch (error) {
console.error('[VIDEO] Nextcloud processing failed:', error);
return [{
type: 'nextcloud',
url: url,
cached: false
}];
}
}
private async getNextcloudDirectUrl(shareUrl: string, shareToken: string): Promise<string | null> {
try {
const baseUrl = shareUrl.split('/s/')[0];
const directUrl = `${baseUrl}/s/${shareToken}/download`;
const response = await fetch(directUrl, { method: 'HEAD' });
if (response.ok) {
return directUrl;
}
return null;
} catch (error) {
console.warn('[VIDEO] Direct URL extraction failed:', error);
return null;
}
}
private async processCdnVideo(url: string): Promise<VideoSource> {
console.log(`[VIDEO] Processing CDN: ${url}`);
return {
type: 'cdn',
url,
cached: false
};
}
private async processLocalVideo(url: string): Promise<VideoSource> {
return {
type: 'local',
url,
cached: true
};
}
private async cacheVideo(sourceUrl: string, identifier: string): Promise<VideoSource | null> {
if (!this.config.enableCaching) {
return null;
}
try {
const fs = await import('fs/promises');
const path = await import('path');
const crypto = await import('crypto');
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);
// Check if already cached
try {
const stat = await fs.stat(cachePath);
console.log(`[VIDEO] Using cached: ${cacheFilename}`);
return {
type: 'local',
url: `/api/video/cached/${cacheFilename}`,
originalUrl: sourceUrl,
cached: true
};
} catch {
// File doesn't exist, proceed with download
}
await fs.mkdir(this.config.cacheDirectory, { recursive: true });
const response = await fetch(sourceUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentLength = parseInt(response.headers.get('content-length') || '0');
if (contentLength > this.config.maxFileSize * 1024 * 1024) {
console.warn(`[VIDEO] File too large for caching: ${Math.round(contentLength / 1024 / 1024)}MB`);
return null;
}
const buffer = await response.arrayBuffer();
await fs.writeFile(cachePath, new Uint8Array(buffer));
console.log(`[VIDEO] Cached: ${cacheFilename}`);
return {
type: 'local',
url: `/api/video/cached/${cacheFilename}`,
originalUrl: sourceUrl,
cached: true
};
} catch (error) {
console.error(`[VIDEO] Caching failed: ${error.message}`);
return null;
}
}
private async enhanceMetadata(source: VideoSource, providedMetadata: Partial<VideoMetadata>): Promise<VideoMetadata> {
const metadata: VideoMetadata = { ...providedMetadata };
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;
}
if (!metadata.title) {
metadata.title = path.basename(source.url, path.extname(source.url));
}
} catch (error) {
console.warn('[VIDEO] Metadata extraction failed:', error);
}
}
if (!metadata.title) {
metadata.title = 'Embedded Video';
}
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')})`;
}
return fallback;
}
private requiresAuthentication(sourceType: VideoSource['type']): boolean {
return sourceType === 'nextcloud';
}
generateVideoHTML(processedVideo: ProcessedVideo, options: {
/**
* Generate basic responsive video HTML
*/
export function generateVideoHTML(
src: string,
options: {
title?: string;
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 {
} = {}
): string {
const {
title = 'Video',
controls = true,
autoplay = false,
muted = false,
loop = false,
preload = 'metadata',
aspectRatio = '16:9',
showMetadata = true
} = options;
const {
controls = true,
autoplay = false,
muted = false,
loop = false,
preload = 'metadata',
aspectRatio = '16:9',
showMetadata = true
} = options;
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
const videoAttributes = [
controls ? 'controls' : '',
autoplay ? 'autoplay' : '',
muted ? 'muted' : '',
loop ? 'loop' : '',
`preload="${preload}"`
].filter(Boolean).join(' ');
if (processedVideo.sources.length === 0) {
return `
<div class="video-container aspect-${aspectRatio}">
<div class="video-error">
<div class="error-icon"></div>
<div>${processedVideo.fallbackText}</div>
</div>
</div>
`;
}
const metadataHTML = showMetadata && title !== 'Video' ? `
<div class="video-metadata">
<div class="video-title">${escapeHtml(title)}</div>
</div>
` : '';
const primarySource = processedVideo.sources[0];
const { metadata } = processedVideo;
// Simple video attributes - no crossorigin complications
const videoAttributes = [
controls ? 'controls' : '',
autoplay ? 'autoplay' : '',
muted ? 'muted' : '',
loop ? 'loop' : '',
`preload="${preload}"`,
metadata.poster ? `poster="${this.escapeHtml(metadata.poster)}"` : '',
`data-video-title="${this.escapeHtml(metadata.title || 'Embedded Video')}"`
].filter(Boolean).join(' ');
const sourceTags = processedVideo.sources
.map(source => {
const mimeType = this.getMimeType(source.url);
return `<source src="${this.escapeHtml(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">${this.escapeHtml(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 html = `
<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] Generated HTML for ${processedVideo.sources[0]?.url}:`);
console.log(html.substring(0, 200) + '...');
return html;
}
private getMimeType(url: string): string {
let extension: string | undefined;
try {
const pathname = new URL(url).pathname;
extension = pathname.split('.').pop()?.toLowerCase();
} catch {
extension = url.split('?')[0].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'
};
return (extension && mimeTypes[extension]) || 'video/mp4';
}
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`;
}
private escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
}
// Export singleton instance
export const videoProcessor = new VideoProcessor();
// 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> {
const processedVideo = await videoProcessor.processVideoUrl(url, metadata);
return videoProcessor.generateVideoHTML(processedVideo, options);
return `
<div class="video-container ${aspectClass}">
<video
src="${escapeHtml(src)}"
${videoAttributes}
style="width: 100%; height: 100%;"
data-video-title="${escapeHtml(title)}"
>
<p>Your browser does not support the video element.</p>
</video>
${metadataHTML}
</div>
`.trim();
}