videos #17
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
|
||||
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
|
||||
# ============================================================================
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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>
|
@ -17,9 +17,9 @@ sections:
|
||||
advanced_topics: false
|
||||
review_status: "published"
|
||||
---
|
||||
|
||||

|
||||
<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!
|
||||
|
||||
|
@ -1,256 +1,57 @@
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
|
||||
//  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=["']([^"']+)["']/);
|
||||
|
||||
await Promise.all(tasks);
|
||||
};
|
||||
};
|
||||
if (srcMatch) {
|
||||
const src = srcMatch[1];
|
||||
const title = titleMatch?.[1] || 'Video';
|
||||
|
||||
// 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;
|
||||
// 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');
|
||||
|
||||
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}">
|
||||
// Create wrapped HTML
|
||||
const wrappedHTML = `
|
||||
<div class="video-container aspect-16:9">
|
||||
<video
|
||||
src="${escapeHtml(src)}"
|
||||
${options.controls ? 'controls' : ''}
|
||||
${options.autoplay ? 'autoplay' : ''}
|
||||
${options.muted ? 'muted' : ''}
|
||||
${options.loop ? 'loop' : ''}
|
||||
${metadata.poster ? `poster="${escapeHtml(metadata.poster)}"` : ''}
|
||||
${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>
|
||||
${options.showMetadata && metadata.title ? `
|
||||
<div class="video-metadata">
|
||||
<div class="video-title">${escapeHtml(metadata.title)}</div>
|
||||
<div class="video-title">${escapeHtml(title)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
`.trim();
|
||||
|
||||
// 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('');
|
||||
// Replace the node
|
||||
parent.children[index] = { type: 'html', value: wrappedHTML };
|
||||
}
|
||||
return '';
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
function escapeHtml(unsafe: string): string {
|
||||
if (typeof unsafe !== 'string') return '';
|
||||
@ -262,80 +63,3 @@ function escapeHtml(unsafe: string): string {
|
||||
.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 {
|
||||
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';
|
@ -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 || '');
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user