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