video implementation

This commit is contained in:
overcuriousity 2025-08-12 21:02:52 +02:00
parent 0e3d654a58
commit b291492e2d
8 changed files with 104 additions and 684 deletions

File diff suppressed because one or more lines are too long

View File

@ -88,11 +88,6 @@ VIDEO_CACHE_MAX_SIZE=2000
# 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
# ============================================================================

View File

@ -10,16 +10,7 @@ export default defineConfig({
markdown: {
remarkPlugins: [
[remarkVideoPlugin, {
enableAsync: true,
defaultOptions: {
controls: true,
autoplay: false,
muted: false,
aspectRatio: '16:9',
showMetadata: true
}
}]
remarkVideoPlugin
],
extendDefaultPlugins: true
},

View File

@ -1,139 +1,41 @@
---
// src/components/Video.astro - SIMPLIFIED using consolidated videoProcessor
import { videoProcessor, type VideoMetadata } from '../utils/videoUtils.js';
// src/components/Video.astro - SIMPLE wrapper component
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,
title = 'Video',
controls = true,
autoplay = false,
muted = false,
loop = false,
preload = 'metadata',
aspectRatio = '16:9',
showMetadata = true,
poster,
width,
height,
fallback
aspectRatio = '16:9'
} = Astro.props;
// SIMPLIFIED: Use consolidated videoProcessor
const metadata: Partial<VideoMetadata> = {
title,
description,
poster
};
const options = {
controls,
autoplay,
muted,
loop,
preload,
aspectRatio,
showMetadata,
width,
height
};
let videoHTML = '';
try {
const processedVideo = await videoProcessor.processVideoUrl(src, metadata);
videoHTML = videoProcessor.generateVideoHTML(processedVideo, options);
} catch (error) {
console.error('[VIDEO COMPONENT] Processing failed:', error);
videoHTML = `
<div class="video-container aspect-${aspectRatio}">
<div class="video-error">
<div class="error-icon">⚠️</div>
<div>${fallback || `Video could not be loaded: ${error.message}`}</div>
</div>
</div>
`;
}
---
<Fragment set:html={videoHTML} />
<script>
// CONSOLIDATED: 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;
// Loading states
video.addEventListener('loadstart', () => container.classList.add('loading'));
video.addEventListener('loadeddata', () => {
container.classList.remove('loading');
container.classList.add('loaded');
});
// Error handling
video.addEventListener('error', (e) => {
console.error('[VIDEO] Load error:', e);
container.classList.remove('loading');
container.classList.add('error');
const errorDiv = document.createElement('div');
errorDiv.className = 'video-error';
errorDiv.innerHTML = `
<div class="error-icon">⚠️</div>
<div>Video could not be loaded</div>
`;
video.style.display = 'none';
container.appendChild(errorDiv);
});
// Fullscreen on double-click
video.addEventListener('dblclick', () => {
if (video.requestFullscreen) {
video.requestFullscreen();
} else if ((video as any).webkitRequestFullscreen) {
(video as any).webkitRequestFullscreen();
}
});
// Keyboard shortcuts
video.addEventListener('keydown', (e: KeyboardEvent) => {
switch(e.key) {
case ' ':
e.preventDefault();
video.paused ? video.play() : video.pause();
break;
case 'f':
e.preventDefault();
if (video.requestFullscreen) video.requestFullscreen();
break;
case 'm':
e.preventDefault();
video.muted = !video.muted;
break;
}
});
});
});
</script>
<div class={`video-container aspect-${aspectRatio}`}>
<video
src={src}
controls={controls}
autoplay={autoplay}
muted={muted}
loop={loop}
style="width: 100%; height: 100%;"
data-video-title={title}
>
<p>Your browser does not support the video element.</p>
</video>
{title !== 'Video' && (
<div class="video-metadata">
<div class="video-title">{title}</div>
</div>
)}
</div>

View File

@ -17,9 +17,9 @@ sections:
advanced_topics: false
review_status: "published"
---
![MinIO Demo](https://console.s3.cc24.dev/browser/forensic-pathways/20250610_AllgemeineForensikII_Vorlesung.mp4 "MinIO Playback")
<video src="https://console.s3.cc24.dev/browser/forensic-pathways/20250610_AllgemeineForensikII_Vorlesung.mp4" controls title="MinIO Video Demo"></video>
<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>
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!

View File

@ -1,257 +1,58 @@
// src/utils/remarkVideoPlugin.ts - Consolidated with videoUtils
// src/utils/remarkVideoPlugin.ts - MINIMAL wrapper only
import { visit } from 'unist-util-visit';
import type { Plugin } from 'unified';
import type { Root } from 'hast';
import { videoProcessor, isVideoUrl } from './videoUtils.js';
interface VideoConfig {
enableAsync?: boolean;
defaultOptions?: {
controls?: boolean;
autoplay?: boolean;
muted?: boolean;
aspectRatio?: '16:9' | '4:3' | '1:1';
showMetadata?: boolean;
};
}
/**
* CONSOLIDATED Remark plugin for video processing
* Uses videoProcessor singleton to avoid code duplication
* MINIMAL plugin - just wraps <video> tags in responsive containers
*/
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 tasks: Array<Promise<void>> = [];
// :::video{...} syntax
visit(tree, 'textDirective', (node: any, index: number | undefined, parent: any) => {
if (node.name === 'video' && typeof index === 'number') {
tasks.push(processVideoDirective(node, index, parent, defaultOptions, enableAsync));
}
});
// :::video ... ::: syntax
visit(tree, 'containerDirective', (node: any, index: number | undefined, parent: any) => {
if (node.name === 'video' && typeof index === 'number') {
tasks.push(processVideoDirective(node, index, parent, defaultOptions, enableAsync));
}
});
// ![alt](video.mp4 "title") syntax
visit(tree, 'image', (node: any, index: number | undefined, parent: any) => {
if (isVideoUrl(node.url) && typeof index === 'number') {
tasks.push(processImageAsVideo(node, index, parent, defaultOptions, enableAsync));
}
});
// [Title](video.mp4) syntax
visit(tree, 'link', (node: any, index: number | undefined, parent: any) => {
if (isVideoUrl(node.url) && typeof index === 'number') {
tasks.push(processLinkAsVideo(node, index, parent, defaultOptions, enableAsync));
}
});
// Raw <video ...> syntax
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') {
tasks.push(processHTMLVideo(node, index, parent, defaultOptions, enableAsync));
// Extract video attributes
const srcMatch = node.value.match(/src=["']([^"']+)["']/);
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
if (srcMatch) {
const src = srcMatch[1];
const title = titleMatch?.[1] || 'Video';
// Check for existing attributes
const hasControls = node.value.includes('controls');
const hasAutoplay = node.value.includes('autoplay');
const hasMuted = node.value.includes('muted');
const hasLoop = node.value.includes('loop');
// Create wrapped HTML
const wrappedHTML = `
<div class="video-container aspect-16:9">
<video
src="${escapeHtml(src)}"
${hasControls ? 'controls' : ''}
${hasAutoplay ? 'autoplay' : ''}
${hasMuted ? 'muted' : ''}
${hasLoop ? 'loop' : ''}
style="width: 100%; height: 100%;"
data-video-title="${escapeHtml(title)}"
>
<p>Your browser does not support the video element.</p>
</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 };
}
}
});
await Promise.all(tasks);
};
};
// CONSOLIDATED: All processing functions use videoProcessor
async function processVideoDirective(
node: any,
index: number,
parent: any,
defaultOptions: VideoConfig['defaultOptions'],
enableAsync: boolean
): Promise<void> {
const attributes = node.attributes || {};
const src = attributes.src;
if (!src) {
console.warn('[VIDEO PLUGIN] Missing src in video directive');
return;
}
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 : '16:9') as '16:9' | '4:3' | '1:1';
const options = {
controls: attributes.controls !== 'false',
autoplay: attributes.autoplay === 'true',
muted: attributes.muted === 'true',
loop: attributes.loop === 'true',
preload: (attributes.preload || 'metadata') as 'none' | 'metadata' | 'auto',
aspectRatio,
showMetadata: attributes.showMetadata !== 'false'
};
const metadata = {
title: attributes.title || extractTextContent(node),
description: attributes.description || attributes.alt,
poster: attributes.poster
};
if (enableAsync) {
const processedVideo = await videoProcessor.processVideoUrl(src, metadata);
const html = videoProcessor.generateVideoHTML(processedVideo, options);
parent.children[index] = { type: 'html', value: html };
} else {
const html = createSimpleVideoHTML(src, metadata, options);
parent.children[index] = { type: 'html', value: html };
}
}
async function processImageAsVideo(
node: any,
index: number,
parent: any,
defaultOptions: VideoConfig['defaultOptions'],
enableAsync: boolean
): Promise<void> {
const metadata = { title: node.title || node.alt, description: node.alt };
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 '16:9' | '4:3' | '1:1',
showMetadata: defaultOptions?.showMetadata ?? true
};
if (enableAsync) {
const processedVideo = await videoProcessor.processVideoUrl(node.url, metadata);
const html = videoProcessor.generateVideoHTML(processedVideo, options);
parent.children[index] = { type: 'html', value: html };
} else {
const html = createSimpleVideoHTML(node.url, metadata, options);
parent.children[index] = { type: 'html', value: html };
}
}
async function processLinkAsVideo(
node: any,
index: number,
parent: any,
defaultOptions: VideoConfig['defaultOptions'],
enableAsync: boolean
): Promise<void> {
const metadata = { title: node.title || extractTextContent(node), description: node.title };
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 '16:9' | '4:3' | '1:1',
showMetadata: defaultOptions?.showMetadata ?? true
};
if (enableAsync) {
const processedVideo = await videoProcessor.processVideoUrl(node.url, metadata);
const html = videoProcessor.generateVideoHTML(processedVideo, options);
parent.children[index] = { type: 'html', value: html };
} else {
const html = createSimpleVideoHTML(node.url, metadata, options);
parent.children[index] = { type: 'html', value: html };
}
}
async function processHTMLVideo(
node: any,
index: number,
parent: any,
defaultOptions: VideoConfig['defaultOptions'],
enableAsync: boolean
): Promise<void> {
const htmlContent = node.value || '';
const srcMatch = htmlContent.match(/src=["']([^"']+)["']/);
if (!srcMatch) return;
const titleMatch = htmlContent.match(/title=["']([^"']+)["']/);
const posterMatch = htmlContent.match(/poster=["']([^"']+)["']/);
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 '16:9' | '4:3' | '1:1',
showMetadata: defaultOptions?.showMetadata ?? true
};
if (enableAsync) {
const processedVideo = await videoProcessor.processVideoUrl(srcMatch[1], metadata);
const html = videoProcessor.generateVideoHTML(processedVideo, options);
parent.children[index] = { type: 'html', value: html };
} else {
const html = createSimpleVideoHTML(srcMatch[1], metadata, options);
parent.children[index] = { type: 'html', value: html };
}
}
// SIMPLIFIED: Fallback for non-async mode
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>
`;
}
// UTILITY FUNCTIONS (moved from duplicated implementations)
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 '';
@ -262,80 +63,3 @@ function escapeHtml(unsafe: string): string {
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// CONSOLIDATED: Content processing using videoProcessor
export async function processVideosInContent(content: string, config: VideoConfig = {}): Promise<string> {
try {
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() || '';
const attributes = parseDirectiveAttributes(attributesStr);
if (!attributes.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'
};
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 {
const videoHTML = createSimpleVideoHTML(attributes.src, metadata, options);
replacements.push({ original: match[0], replacement: videoHTML });
}
} catch (error) {
console.error('[VIDEO PLUGIN] Error processing directive:', error);
}
}
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;
}
}
function parseDirectiveAttributes(attributesStr: string): Record<string, string> {
const attributes: Record<string, string> = {};
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;
}

View File

@ -1,3 +1,6 @@
// src/utils/toolHelpers.ts - CONSOLIDATED to remove code duplication
// Re-export functions from clientUtils to avoid duplication
export interface Tool {
name: string;
type?: 'software' | 'method' | 'concept';
@ -13,31 +16,9 @@ export interface Tool {
related_concepts?: string[];
}
export function createToolSlug(toolName: string): string {
if (!toolName || typeof toolName !== 'string') {
console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
return '';
}
return toolName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove duplicate hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
export function findToolByIdentifier(tools: Tool[], identifier: string): Tool | undefined {
if (!identifier || !Array.isArray(tools)) return undefined;
return tools.find(tool =>
tool.name === identifier ||
createToolSlug(tool.name) === identifier.toLowerCase()
);
}
export function isToolHosted(tool: Tool): boolean {
return tool.projectUrl !== undefined &&
tool.projectUrl !== null &&
tool.projectUrl !== "" &&
tool.projectUrl.trim() !== "";
}
// CONSOLIDATED: Import shared utilities instead of duplicating
export {
createToolSlug,
findToolByIdentifier,
isToolHosted
} from './clientUtils.js';

View File

@ -1,11 +1,9 @@
// src/utils/videoUtils.ts - Fixed version with ORB resolution
// src/utils/videoUtils.ts - NEXTCLOUD ONLY
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';
type: 'nextcloud' | 'cdn' | 'local';
url: string;
originalUrl?: string;
cached?: boolean;
@ -40,7 +38,6 @@ interface VideoConfig {
export class VideoProcessor {
private config: VideoConfig;
private nextcloudUploader: NextcloudUploader;
private s3Client?: S3Client;
constructor() {
this.config = {
@ -51,20 +48,8 @@ export class VideoProcessor {
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,
region: 'us-east-1',
forcePathStyle: true,
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY,
secretAccessKey: process.env.MINIO_SECRET_KEY
}
});
console.log(`[VIDEO] MinIO client initialized: ${process.env.MINIO_URL}`);
}
this.nextcloudUploader = new NextcloudUploader();
console.log('[VIDEO] Nextcloud-only video processor initialized');
}
async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
@ -80,12 +65,8 @@ export class VideoProcessor {
case 'nextcloud':
sources.push(...await this.processNextcloudVideo(url));
break;
case 's3':
case 'minio':
sources.push(...await this.processS3MinioVideo(url, videoSource.type));
break;
case 'direct':
sources.push(await this.processDirectVideo(url));
case 'cdn':
sources.push(await this.processCdnVideo(url));
break;
case 'local':
sources.push(await this.processLocalVideo(url));
@ -113,96 +94,23 @@ export class VideoProcessor {
}
private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
// Check MinIO by endpoint first
try {
const minioHost = process.env.MINIO_URL ? new URL(process.env.MINIO_URL).host : null;
const urlHost = new URL(url).host;
if (minioHost && urlHost === minioHost) {
console.log(`[VIDEO] Detected MinIO by host: ${minioHost}`);
return { type: 'minio', url };
}
} catch {
// Ignore URL parse errors
}
console.log(`[VIDEO] Identifying source for: ${url}`);
// Pattern-based detection
// Check for Nextcloud patterns
if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
console.log(`[VIDEO] Detected Nextcloud URL`);
return { type: 'nextcloud', url };
}
if (url.includes('amazonaws.com') || url.includes('.s3.')) {
return { type: 's3', url };
}
if (url.includes('minio') || this.isMinioPattern(url)) {
return { type: 'minio', url };
}
// Local files
if (url.startsWith('/') && !url.startsWith('http')) {
console.log(`[VIDEO] Detected local file`);
return { type: 'local', url };
}
return { type: 'direct', url };
}
private isMinioPattern(url: string): boolean {
// Match MinIO console URLs and bucket URLs
return /console\.[^\/]*\/browser\/[^\/]+\//.test(url) ||
/:\d+\/[^\/]+\/.*\.(mp4|webm|ogg|mov|avi)/i.test(url);
}
private async processS3MinioVideo(url: string, type: 's3' | 'minio'): Promise<VideoSource[]> {
console.log(`[VIDEO] Processing ${type}: ${url}`);
if (type === 'minio' && this.s3Client) {
const parsed = this.normalizeMinioUrl(url);
if (parsed) {
try {
// FIXED: Remove problematic response headers that cause ORB
const cmd = new GetObjectCommand({
Bucket: parsed.bucket,
Key: parsed.key
// Remove ResponseContentType, ResponseContentDisposition, ResponseCacheControl
});
const signed = await getSignedUrl(this.s3Client, cmd, { expiresIn: 3600 });
console.log(`[VIDEO] MinIO pre-signed: ${signed.substring(0, 80)}...`);
return [{ type: 'minio', url: signed, originalUrl: url, cached: false }];
} catch (e) {
console.warn('[VIDEO] 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);
// FIXED: Handle console URLs properly
// Pattern: https://console.s3.cc24.dev/browser/bucket-name/path/to/file.mp4
if (u.pathname.includes('/browser/')) {
const browserIndex = u.pathname.indexOf('/browser/');
const pathAfterBrowser = u.pathname.substring(browserIndex + '/browser/'.length);
const parts = pathAfterBrowser.split('/');
if (parts.length >= 2) {
const bucket = parts[0];
const key = parts.slice(1).join('/');
console.log(`[VIDEO] Parsed console URL - Bucket: ${bucket}, Key: ${key}`);
return { bucket, key };
}
}
// Standard path-style: /bucket/key
const parts = u.pathname.replace(/^\/+/, '').split('/');
if (parts.length >= 2) {
return { bucket: parts[0], key: parts.slice(1).join('/') };
}
return null;
} catch (error) {
console.error('[VIDEO] URL parsing failed:', error);
return null;
}
// Everything else is a CDN
console.log(`[VIDEO] Detected CDN URL`);
return { type: 'cdn', url };
}
private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
@ -218,6 +126,7 @@ export class VideoProcessor {
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) {
@ -225,6 +134,7 @@ export class VideoProcessor {
}
}
// Always include direct source as fallback
sources.push({
type: 'nextcloud',
url: directUrl || url,
@ -262,9 +172,10 @@ export class VideoProcessor {
}
}
private async processDirectVideo(url: string): Promise<VideoSource> {
private async processCdnVideo(url: string): Promise<VideoSource> {
console.log(`[VIDEO] Processing CDN: ${url}`);
return {
type: 'direct',
type: 'cdn',
url,
cached: false
};
@ -293,6 +204,7 @@ export class VideoProcessor {
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}`);
@ -327,8 +239,6 @@ export class VideoProcessor {
console.log(`[VIDEO] Cached: ${cacheFilename}`);
await this.emergencyCleanupIfNeeded();
return {
type: 'local',
url: `/api/video/cached/${cacheFilename}`,
@ -342,49 +252,6 @@ export class VideoProcessor {
}
}
private async emergencyCleanupIfNeeded(): Promise<void> {
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}`))
);
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 };
})
);
const totalSize = fileStats.reduce((sum, { stat }) => sum + stat.size, 0);
const maxBytes = this.config.maxCacheSize * 1024 * 1024;
if (totalSize > maxBytes) {
console.warn(`[VIDEO] Cache cleanup needed: ${Math.round(totalSize / 1024 / 1024)}MB > ${this.config.maxCacheSize}MB`);
fileStats.sort((a, b) => a.stat.atime.getTime() - b.stat.atime.getTime());
const targetSize = maxBytes * 0.8;
let currentSize = totalSize;
for (const { file, filePath, stat } of fileStats) {
if (currentSize <= targetSize) break;
await fs.unlink(filePath);
currentSize -= stat.size;
console.log(`[VIDEO] Cleaned up: ${file}`);
}
}
} catch (error) {
console.error('[VIDEO] Cleanup failed:', error);
}
}
private async enhanceMetadata(source: VideoSource, providedMetadata: Partial<VideoMetadata>): Promise<VideoMetadata> {
const metadata: VideoMetadata = { ...providedMetadata };
@ -467,15 +334,12 @@ export class VideoProcessor {
const primarySource = processedVideo.sources[0];
const { metadata } = processedVideo;
// FIXED: Only add crossorigin for trusted sources that actually need it
const needsCrossOrigin = this.shouldUseCrossOrigin(primarySource);
// Simple video attributes - no crossorigin complications
const videoAttributes = [
controls ? 'controls' : '',
autoplay ? 'autoplay' : '',
muted ? 'muted' : '',
loop ? 'loop' : '',
needsCrossOrigin ? 'crossorigin="anonymous"' : '',
`preload="${preload}"`,
metadata.poster ? `poster="${this.escapeHtml(metadata.poster)}"` : '',
`data-video-title="${this.escapeHtml(metadata.title || 'Embedded Video')}"`
@ -499,7 +363,7 @@ export class VideoProcessor {
</div>
` : '';
return `
const html = `
<div class="video-container aspect-${aspectRatio}">
<video ${videoAttributes}>
${sourceTags}
@ -508,42 +372,11 @@ export class VideoProcessor {
${metadataHTML}
</div>
`.trim();
}
// FIXED: More intelligent cross-origin detection
private shouldUseCrossOrigin(source: VideoSource): boolean {
// Never use crossorigin for local/cached files
if (source.type === 'local' || source.cached) {
return false;
}
console.log(`[VIDEO] Generated HTML for ${processedVideo.sources[0]?.url}:`);
console.log(html.substring(0, 200) + '...');
// Don't use crossorigin for direct MinIO URLs (they're pre-signed)
if (source.type === 'minio') {
return false;
}
// Only use crossorigin for external domains that we know support CORS
if (source.type === 'direct') {
try {
const url = new URL(source.url);
const trustedDomains = ['youtube.com', 'youtu.be', 'vimeo.com'];
return trustedDomains.some(domain => url.hostname.includes(domain));
} catch {
return false;
}
}
return false;
}
private isSameOrigin(url: string): boolean {
try {
const siteOrigin = process.env.PUBLIC_SITE_ORIGIN || '';
if (!siteOrigin) return false;
return new URL(url).origin === new URL(siteOrigin).origin;
} catch {
return false;
}
return html;
}
private getMimeType(url: string): string {
@ -614,9 +447,3 @@ export async function processVideoEmbed(url: string, metadata?: Partial<VideoMet
const processedVideo = await videoProcessor.processVideoUrl(url, metadata);
return videoProcessor.generateVideoHTML(processedVideo, options);
}
export function isVideoUrl(url: string): boolean {
const videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi'];
const extension = url.split('.').pop()?.toLowerCase();
return videoExtensions.includes(extension || '');
}