video implementation
This commit is contained in:
		
							parent
							
								
									0e3d654a58
								
							
						
					
					
						commit
						b291492e2d
					
				
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@ -88,11 +88,6 @@ VIDEO_CACHE_MAX_SIZE=2000
 | 
				
			|||||||
# Videos larger than this will stream directly without caching
 | 
					# Videos larger than this will stream directly without caching
 | 
				
			||||||
VIDEO_MAX_SIZE=200
 | 
					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
 | 
					# CACHING BEHAVIOR
 | 
				
			||||||
# ============================================================================
 | 
					# ============================================================================
 | 
				
			||||||
 | 
				
			|||||||
@ -10,16 +10,7 @@ export default defineConfig({
 | 
				
			|||||||
  
 | 
					  
 | 
				
			||||||
  markdown: {
 | 
					  markdown: {
 | 
				
			||||||
    remarkPlugins: [
 | 
					    remarkPlugins: [
 | 
				
			||||||
      [remarkVideoPlugin, {
 | 
					      remarkVideoPlugin
 | 
				
			||||||
        enableAsync: true,
 | 
					 | 
				
			||||||
        defaultOptions: {
 | 
					 | 
				
			||||||
          controls: true,
 | 
					 | 
				
			||||||
          autoplay: false,
 | 
					 | 
				
			||||||
          muted: false,
 | 
					 | 
				
			||||||
          aspectRatio: '16:9',
 | 
					 | 
				
			||||||
          showMetadata: true
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }]
 | 
					 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    extendDefaultPlugins: true
 | 
					    extendDefaultPlugins: true
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
				
			|||||||
@ -1,139 +1,41 @@
 | 
				
			|||||||
---
 | 
					---
 | 
				
			||||||
// src/components/Video.astro - SIMPLIFIED using consolidated videoProcessor
 | 
					// src/components/Video.astro - SIMPLE wrapper component
 | 
				
			||||||
import { videoProcessor, type VideoMetadata } from '../utils/videoUtils.js';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface Props {
 | 
					export interface Props {
 | 
				
			||||||
  src: string;
 | 
					  src: string;
 | 
				
			||||||
  title?: string;
 | 
					  title?: string;
 | 
				
			||||||
  description?: string;
 | 
					 | 
				
			||||||
  controls?: boolean;
 | 
					  controls?: boolean;
 | 
				
			||||||
  autoplay?: boolean;
 | 
					  autoplay?: boolean;
 | 
				
			||||||
  muted?: boolean;
 | 
					  muted?: boolean;
 | 
				
			||||||
  loop?: boolean;
 | 
					  loop?: boolean;
 | 
				
			||||||
  preload?: 'none' | 'metadata' | 'auto';
 | 
					 | 
				
			||||||
  aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
					  aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
				
			||||||
  showMetadata?: boolean;
 | 
					 | 
				
			||||||
  poster?: string;
 | 
					 | 
				
			||||||
  width?: string;
 | 
					 | 
				
			||||||
  height?: string;
 | 
					 | 
				
			||||||
  fallback?: string;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const {
 | 
					const {
 | 
				
			||||||
  src,
 | 
					  src,
 | 
				
			||||||
  title,
 | 
					  title = 'Video',
 | 
				
			||||||
  description,
 | 
					 | 
				
			||||||
  controls = true,
 | 
					  controls = true,
 | 
				
			||||||
  autoplay = false,
 | 
					  autoplay = false,
 | 
				
			||||||
  muted = false,
 | 
					  muted = false,
 | 
				
			||||||
  loop = false,
 | 
					  loop = false,
 | 
				
			||||||
  preload = 'metadata',
 | 
					  aspectRatio = '16:9'
 | 
				
			||||||
  aspectRatio = '16:9',
 | 
					 | 
				
			||||||
  showMetadata = true,
 | 
					 | 
				
			||||||
  poster,
 | 
					 | 
				
			||||||
  width,
 | 
					 | 
				
			||||||
  height,
 | 
					 | 
				
			||||||
  fallback
 | 
					 | 
				
			||||||
} = Astro.props;
 | 
					} = 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} />
 | 
					<div class={`video-container aspect-${aspectRatio}`}>
 | 
				
			||||||
 | 
					  <video 
 | 
				
			||||||
<script>
 | 
					    src={src}
 | 
				
			||||||
  // CONSOLIDATED: Client-side video enhancement
 | 
					    controls={controls}
 | 
				
			||||||
  document.addEventListener('DOMContentLoaded', () => {
 | 
					    autoplay={autoplay}
 | 
				
			||||||
    const videos = document.querySelectorAll('.video-container video') as NodeListOf<HTMLVideoElement>;
 | 
					    muted={muted}
 | 
				
			||||||
    
 | 
					    loop={loop}
 | 
				
			||||||
    videos.forEach((video: HTMLVideoElement) => {
 | 
					    style="width: 100%; height: 100%;"
 | 
				
			||||||
      const container = video.closest('.video-container') as HTMLElement;
 | 
					    data-video-title={title}
 | 
				
			||||||
      if (!container) return;
 | 
					  >
 | 
				
			||||||
      
 | 
					    <p>Your browser does not support the video element.</p>
 | 
				
			||||||
      // Loading states
 | 
					  </video>
 | 
				
			||||||
      video.addEventListener('loadstart', () => container.classList.add('loading'));
 | 
					  {title !== 'Video' && (
 | 
				
			||||||
      video.addEventListener('loadeddata', () => {
 | 
					    <div class="video-metadata">
 | 
				
			||||||
        container.classList.remove('loading');
 | 
					      <div class="video-title">{title}</div>
 | 
				
			||||||
        container.classList.add('loaded');
 | 
					    </div>
 | 
				
			||||||
      });
 | 
					  )}
 | 
				
			||||||
      
 | 
					</div>
 | 
				
			||||||
      // 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>
 | 
					 | 
				
			||||||
@ -17,9 +17,9 @@ sections:
 | 
				
			|||||||
  advanced_topics: false
 | 
					  advanced_topics: false
 | 
				
			||||||
review_status: "published"
 | 
					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://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></video>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
 | 
					> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -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 { visit } from 'unist-util-visit';
 | 
				
			||||||
import type { Plugin } from 'unified';
 | 
					import type { Plugin } from 'unified';
 | 
				
			||||||
import type { Root } from 'hast';
 | 
					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
 | 
					 * MINIMAL plugin - just wraps <video> tags in responsive containers
 | 
				
			||||||
 * Uses videoProcessor singleton to avoid code duplication
 | 
					 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const remarkVideoPlugin: Plugin<[VideoConfig?], Root> = (config = {}) => {
 | 
					export const remarkVideoPlugin: Plugin<[], Root> = () => {
 | 
				
			||||||
  const {
 | 
					  return (tree: Root) => {
 | 
				
			||||||
    enableAsync = true,
 | 
					    // Find HTML nodes containing <video> tags
 | 
				
			||||||
    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));
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    //  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
 | 
					 | 
				
			||||||
    visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
 | 
					    visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
 | 
				
			||||||
      if (node.value && node.value.includes('<video') && typeof index === 'number') {
 | 
					      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 {
 | 
					function escapeHtml(unsafe: string): string {
 | 
				
			||||||
  if (typeof unsafe !== 'string') return '';
 | 
					  if (typeof unsafe !== 'string') return '';
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
@ -262,80 +63,3 @@ function escapeHtml(unsafe: string): string {
 | 
				
			|||||||
    .replace(/"/g, """)
 | 
					    .replace(/"/g, """)
 | 
				
			||||||
    .replace(/'/g, "'");
 | 
					    .replace(/'/g, "'");
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
// 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;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,3 +1,6 @@
 | 
				
			|||||||
 | 
					// src/utils/toolHelpers.ts - CONSOLIDATED to remove code duplication
 | 
				
			||||||
 | 
					// Re-export functions from clientUtils to avoid duplication
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface Tool {
 | 
					export interface Tool {
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
  type?: 'software' | 'method' | 'concept';
 | 
					  type?: 'software' | 'method' | 'concept';
 | 
				
			||||||
@ -13,31 +16,9 @@ export interface Tool {
 | 
				
			|||||||
  related_concepts?: string[];
 | 
					  related_concepts?: string[];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function createToolSlug(toolName: string): string {
 | 
					// CONSOLIDATED: Import shared utilities instead of duplicating
 | 
				
			||||||
  if (!toolName || typeof toolName !== 'string') {
 | 
					export { 
 | 
				
			||||||
    console.warn('[toolHelpers] Invalid toolName provided to createToolSlug:', toolName);
 | 
					  createToolSlug, 
 | 
				
			||||||
    return '';
 | 
					  findToolByIdentifier, 
 | 
				
			||||||
  }
 | 
					  isToolHosted 
 | 
				
			||||||
  
 | 
					} from './clientUtils.js';
 | 
				
			||||||
  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() !== "";
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -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 { NextcloudUploader } from './nextcloud.js';
 | 
				
			||||||
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
 | 
					 | 
				
			||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 | 
					 | 
				
			||||||
import 'dotenv/config';
 | 
					import 'dotenv/config';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface VideoSource {
 | 
					export interface VideoSource {
 | 
				
			||||||
  type: 'nextcloud' | 's3' | 'minio' | 'direct' | 'local';
 | 
					  type: 'nextcloud' | 'cdn' | 'local';
 | 
				
			||||||
  url: string;
 | 
					  url: string;
 | 
				
			||||||
  originalUrl?: string;
 | 
					  originalUrl?: string;
 | 
				
			||||||
  cached?: boolean;
 | 
					  cached?: boolean;
 | 
				
			||||||
@ -40,7 +38,6 @@ interface VideoConfig {
 | 
				
			|||||||
export class VideoProcessor {
 | 
					export class VideoProcessor {
 | 
				
			||||||
  private config: VideoConfig;
 | 
					  private config: VideoConfig;
 | 
				
			||||||
  private nextcloudUploader: NextcloudUploader;
 | 
					  private nextcloudUploader: NextcloudUploader;
 | 
				
			||||||
  private s3Client?: S3Client;
 | 
					 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  constructor() {
 | 
					  constructor() {
 | 
				
			||||||
    this.config = {
 | 
					    this.config = {
 | 
				
			||||||
@ -51,20 +48,8 @@ export class VideoProcessor {
 | 
				
			|||||||
      maxFileSize: parseInt(process.env.VIDEO_MAX_SIZE || '200')
 | 
					      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();
 | 
					    this.nextcloudUploader = new NextcloudUploader();
 | 
				
			||||||
 | 
					    console.log('[VIDEO] Nextcloud-only video processor initialized');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
 | 
					  async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
 | 
				
			||||||
@ -80,12 +65,8 @@ export class VideoProcessor {
 | 
				
			|||||||
        case 'nextcloud':
 | 
					        case 'nextcloud':
 | 
				
			||||||
          sources.push(...await this.processNextcloudVideo(url));
 | 
					          sources.push(...await this.processNextcloudVideo(url));
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        case 's3':
 | 
					        case 'cdn':
 | 
				
			||||||
        case 'minio':
 | 
					          sources.push(await this.processCdnVideo(url));
 | 
				
			||||||
          sources.push(...await this.processS3MinioVideo(url, videoSource.type));
 | 
					 | 
				
			||||||
          break;
 | 
					 | 
				
			||||||
        case 'direct':
 | 
					 | 
				
			||||||
          sources.push(await this.processDirectVideo(url));
 | 
					 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        case 'local':
 | 
					        case 'local':
 | 
				
			||||||
          sources.push(await this.processLocalVideo(url));
 | 
					          sources.push(await this.processLocalVideo(url));
 | 
				
			||||||
@ -113,96 +94,23 @@ export class VideoProcessor {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
 | 
					  private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
 | 
				
			||||||
    // Check MinIO by endpoint first
 | 
					    console.log(`[VIDEO] Identifying source for: ${url}`);
 | 
				
			||||||
    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
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    // Pattern-based detection
 | 
					    // Check for Nextcloud patterns
 | 
				
			||||||
    if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
 | 
					    if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
 | 
				
			||||||
 | 
					      console.log(`[VIDEO] Detected Nextcloud URL`);
 | 
				
			||||||
      return { type: 'nextcloud', url };
 | 
					      return { type: 'nextcloud', url };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if (url.includes('amazonaws.com') || url.includes('.s3.')) {
 | 
					    
 | 
				
			||||||
      return { type: 's3', url };
 | 
					    // Local files
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (url.includes('minio') || this.isMinioPattern(url)) {
 | 
					 | 
				
			||||||
      return { type: 'minio', url };
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    if (url.startsWith('/') && !url.startsWith('http')) {
 | 
					    if (url.startsWith('/') && !url.startsWith('http')) {
 | 
				
			||||||
 | 
					      console.log(`[VIDEO] Detected local file`);
 | 
				
			||||||
      return { type: 'local', url };
 | 
					      return { type: 'local', url };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return { type: 'direct', url };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
  private isMinioPattern(url: string): boolean {
 | 
					    // Everything else is a CDN
 | 
				
			||||||
    // Match MinIO console URLs and bucket URLs
 | 
					    console.log(`[VIDEO] Detected CDN URL`);
 | 
				
			||||||
    return /console\.[^\/]*\/browser\/[^\/]+\//.test(url) || 
 | 
					    return { type: 'cdn', 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;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
 | 
					  private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
 | 
				
			||||||
@ -218,6 +126,7 @@ export class VideoProcessor {
 | 
				
			|||||||
      const directUrl = await this.getNextcloudDirectUrl(url, shareToken);
 | 
					      const directUrl = await this.getNextcloudDirectUrl(url, shareToken);
 | 
				
			||||||
      const sources: VideoSource[] = [];
 | 
					      const sources: VideoSource[] = [];
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
 | 
					      // Try caching if enabled
 | 
				
			||||||
      if (this.config.enableCaching && directUrl) {
 | 
					      if (this.config.enableCaching && directUrl) {
 | 
				
			||||||
        const cachedSource = await this.cacheVideo(directUrl, shareToken);
 | 
					        const cachedSource = await this.cacheVideo(directUrl, shareToken);
 | 
				
			||||||
        if (cachedSource) {
 | 
					        if (cachedSource) {
 | 
				
			||||||
@ -225,6 +134,7 @@ export class VideoProcessor {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
 | 
					      // Always include direct source as fallback
 | 
				
			||||||
      sources.push({
 | 
					      sources.push({
 | 
				
			||||||
        type: 'nextcloud',
 | 
					        type: 'nextcloud',
 | 
				
			||||||
        url: directUrl || url,
 | 
					        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 {
 | 
					    return {
 | 
				
			||||||
      type: 'direct',
 | 
					      type: 'cdn',
 | 
				
			||||||
      url,
 | 
					      url,
 | 
				
			||||||
      cached: false
 | 
					      cached: false
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
@ -293,6 +204,7 @@ export class VideoProcessor {
 | 
				
			|||||||
      const cacheFilename = `${identifier}_${urlHash}${extension}`;
 | 
					      const cacheFilename = `${identifier}_${urlHash}${extension}`;
 | 
				
			||||||
      const cachePath = path.join(this.config.cacheDirectory, cacheFilename);
 | 
					      const cachePath = path.join(this.config.cacheDirectory, cacheFilename);
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
 | 
					      // Check if already cached
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        const stat = await fs.stat(cachePath);
 | 
					        const stat = await fs.stat(cachePath);
 | 
				
			||||||
        console.log(`[VIDEO] Using cached: ${cacheFilename}`);
 | 
					        console.log(`[VIDEO] Using cached: ${cacheFilename}`);
 | 
				
			||||||
@ -327,8 +239,6 @@ export class VideoProcessor {
 | 
				
			|||||||
      
 | 
					      
 | 
				
			||||||
      console.log(`[VIDEO] Cached: ${cacheFilename}`);
 | 
					      console.log(`[VIDEO] Cached: ${cacheFilename}`);
 | 
				
			||||||
      
 | 
					      
 | 
				
			||||||
      await this.emergencyCleanupIfNeeded();
 | 
					 | 
				
			||||||
      
 | 
					 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        type: 'local',
 | 
					        type: 'local',
 | 
				
			||||||
        url: `/api/video/cached/${cacheFilename}`,
 | 
					        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> {
 | 
					  private async enhanceMetadata(source: VideoSource, providedMetadata: Partial<VideoMetadata>): Promise<VideoMetadata> {
 | 
				
			||||||
    const metadata: VideoMetadata = { ...providedMetadata };
 | 
					    const metadata: VideoMetadata = { ...providedMetadata };
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
@ -467,15 +334,12 @@ export class VideoProcessor {
 | 
				
			|||||||
    const primarySource = processedVideo.sources[0];
 | 
					    const primarySource = processedVideo.sources[0];
 | 
				
			||||||
    const { metadata } = processedVideo;
 | 
					    const { metadata } = processedVideo;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // FIXED: Only add crossorigin for trusted sources that actually need it
 | 
					    // Simple video attributes - no crossorigin complications
 | 
				
			||||||
    const needsCrossOrigin = this.shouldUseCrossOrigin(primarySource);
 | 
					 | 
				
			||||||
    
 | 
					 | 
				
			||||||
    const videoAttributes = [
 | 
					    const videoAttributes = [
 | 
				
			||||||
      controls ? 'controls' : '',
 | 
					      controls ? 'controls' : '',
 | 
				
			||||||
      autoplay ? 'autoplay' : '',
 | 
					      autoplay ? 'autoplay' : '',
 | 
				
			||||||
      muted ? 'muted' : '',
 | 
					      muted ? 'muted' : '',
 | 
				
			||||||
      loop ? 'loop' : '',
 | 
					      loop ? 'loop' : '',
 | 
				
			||||||
      needsCrossOrigin ? 'crossorigin="anonymous"' : '',
 | 
					 | 
				
			||||||
      `preload="${preload}"`,
 | 
					      `preload="${preload}"`,
 | 
				
			||||||
      metadata.poster ? `poster="${this.escapeHtml(metadata.poster)}"` : '',
 | 
					      metadata.poster ? `poster="${this.escapeHtml(metadata.poster)}"` : '',
 | 
				
			||||||
      `data-video-title="${this.escapeHtml(metadata.title || 'Embedded Video')}"`
 | 
					      `data-video-title="${this.escapeHtml(metadata.title || 'Embedded Video')}"`
 | 
				
			||||||
@ -499,7 +363,7 @@ export class VideoProcessor {
 | 
				
			|||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    ` : '';
 | 
					    ` : '';
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    return `
 | 
					    const html = `
 | 
				
			||||||
      <div class="video-container aspect-${aspectRatio}">
 | 
					      <div class="video-container aspect-${aspectRatio}">
 | 
				
			||||||
        <video ${videoAttributes}>
 | 
					        <video ${videoAttributes}>
 | 
				
			||||||
          ${sourceTags}
 | 
					          ${sourceTags}
 | 
				
			||||||
@ -508,42 +372,11 @@ export class VideoProcessor {
 | 
				
			|||||||
        ${metadataHTML}
 | 
					        ${metadataHTML}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    `.trim();
 | 
					    `.trim();
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
  // FIXED: More intelligent cross-origin detection
 | 
					    console.log(`[VIDEO] Generated HTML for ${processedVideo.sources[0]?.url}:`);
 | 
				
			||||||
  private shouldUseCrossOrigin(source: VideoSource): boolean {
 | 
					    console.log(html.substring(0, 200) + '...');
 | 
				
			||||||
    // Never use crossorigin for local/cached files
 | 
					 | 
				
			||||||
    if (source.type === 'local' || source.cached) {
 | 
					 | 
				
			||||||
      return false;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    // Don't use crossorigin for direct MinIO URLs (they're pre-signed)
 | 
					    return html;
 | 
				
			||||||
    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;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  
 | 
					  
 | 
				
			||||||
  private getMimeType(url: string): string {
 | 
					  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);
 | 
					  const processedVideo = await videoProcessor.processVideoUrl(url, metadata);
 | 
				
			||||||
  return videoProcessor.generateVideoHTML(processedVideo, options);
 | 
					  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 || '');
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user