videos #17
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
// src/components/Video.astro
|
// src/components/Video.astro - SIMPLIFIED using consolidated videoProcessor
|
||||||
import { videoProcessor, type VideoMetadata } from '../utils/videoUtils.js';
|
import { videoProcessor, type VideoMetadata } from '../utils/videoUtils.js';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
@ -36,7 +36,7 @@ const {
|
|||||||
fallback
|
fallback
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
// Process the video URL and generate optimized sources
|
// SIMPLIFIED: Use consolidated videoProcessor
|
||||||
const metadata: Partial<VideoMetadata> = {
|
const metadata: Partial<VideoMetadata> = {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -55,21 +55,18 @@ const options = {
|
|||||||
height
|
height
|
||||||
};
|
};
|
||||||
|
|
||||||
let processedVideo;
|
|
||||||
let videoHTML = '';
|
let videoHTML = '';
|
||||||
let errorMessage = '';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
processedVideo = await videoProcessor.processVideoUrl(src, metadata);
|
const processedVideo = await videoProcessor.processVideoUrl(src, metadata);
|
||||||
videoHTML = videoProcessor.generateVideoHTML(processedVideo, options);
|
videoHTML = videoProcessor.generateVideoHTML(processedVideo, options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[VIDEO COMPONENT] Processing failed:', error);
|
console.error('[VIDEO COMPONENT] Processing failed:', error);
|
||||||
errorMessage = error.message;
|
|
||||||
videoHTML = `
|
videoHTML = `
|
||||||
<div class="video-container aspect-${aspectRatio}">
|
<div class="video-container aspect-${aspectRatio}">
|
||||||
<div class="video-error">
|
<div class="video-error">
|
||||||
<div class="error-icon">⚠️</div>
|
<div class="error-icon">⚠️</div>
|
||||||
<div>${fallback || `Video could not be loaded: ${errorMessage}`}</div>
|
<div>${fallback || `Video could not be loaded: ${error.message}`}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -79,7 +76,7 @@ try {
|
|||||||
<Fragment set:html={videoHTML} />
|
<Fragment set:html={videoHTML} />
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Client-side video enhancement
|
// CONSOLIDATED: Client-side video enhancement
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const videos = document.querySelectorAll('.video-container video') as NodeListOf<HTMLVideoElement>;
|
const videos = document.querySelectorAll('.video-container video') as NodeListOf<HTMLVideoElement>;
|
||||||
|
|
||||||
@ -87,22 +84,19 @@ try {
|
|||||||
const container = video.closest('.video-container') as HTMLElement;
|
const container = video.closest('.video-container') as HTMLElement;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Add loading state
|
// Loading states
|
||||||
video.addEventListener('loadstart', () => {
|
video.addEventListener('loadstart', () => container.classList.add('loading'));
|
||||||
container.classList.add('loading');
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('loadeddata', () => {
|
video.addEventListener('loadeddata', () => {
|
||||||
container.classList.remove('loading');
|
container.classList.remove('loading');
|
||||||
container.classList.add('loaded');
|
container.classList.add('loaded');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Error handling
|
||||||
video.addEventListener('error', (e) => {
|
video.addEventListener('error', (e) => {
|
||||||
console.error('[VIDEO] Load error:', e);
|
console.error('[VIDEO] Load error:', e);
|
||||||
container.classList.remove('loading');
|
container.classList.remove('loading');
|
||||||
container.classList.add('error');
|
container.classList.add('error');
|
||||||
|
|
||||||
// Show error message
|
|
||||||
const errorDiv = document.createElement('div');
|
const errorDiv = document.createElement('div');
|
||||||
errorDiv.className = 'video-error';
|
errorDiv.className = 'video-error';
|
||||||
errorDiv.innerHTML = `
|
errorDiv.innerHTML = `
|
||||||
@ -110,38 +104,29 @@ try {
|
|||||||
<div>Video could not be loaded</div>
|
<div>Video could not be loaded</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
(video as HTMLElement).style.display = 'none';
|
video.style.display = 'none';
|
||||||
container.appendChild(errorDiv);
|
container.appendChild(errorDiv);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle fullscreen
|
// Fullscreen on double-click
|
||||||
video.addEventListener('dblclick', () => {
|
video.addEventListener('dblclick', () => {
|
||||||
const videoElement = video as any;
|
|
||||||
if (video.requestFullscreen) {
|
if (video.requestFullscreen) {
|
||||||
video.requestFullscreen();
|
video.requestFullscreen();
|
||||||
} else if (videoElement.webkitRequestFullscreen) {
|
} else if ((video as any).webkitRequestFullscreen) {
|
||||||
videoElement.webkitRequestFullscreen();
|
(video as any).webkitRequestFullscreen();
|
||||||
} else if (videoElement.msRequestFullscreen) {
|
|
||||||
videoElement.msRequestFullscreen();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
video.addEventListener('keydown', (e: KeyboardEvent) => {
|
video.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
case ' ':
|
case ' ':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (video.paused) {
|
video.paused ? video.play() : video.pause();
|
||||||
video.play();
|
|
||||||
} else {
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'f':
|
case 'f':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (video.requestFullscreen) {
|
if (video.requestFullscreen) video.requestFullscreen();
|
||||||
video.requestFullscreen();
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'm':
|
case 'm':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -152,34 +137,3 @@ try {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Component-specific enhancements */
|
|
||||||
.video-container.loading video {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-container.loading::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
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;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-container.error video {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
|
||||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -174,7 +174,6 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
};
|
};
|
||||||
|
|
||||||
(window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
|
(window as any).showToolDetails = function(toolName: string, modalType: string = 'primary') {
|
||||||
|
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 50;
|
const maxAttempts = 50;
|
||||||
|
|
||||||
@ -229,7 +228,7 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
authRequired: data.aiAuthRequired,
|
authRequired: data.aiAuthRequired,
|
||||||
expires: data.expires
|
expires: data.expires
|
||||||
};
|
};
|
||||||
case 'gatedcontent': // ADD THIS CASE
|
case 'gatedcontent':
|
||||||
return {
|
return {
|
||||||
authenticated: data.gatedContentAuthenticated,
|
authenticated: data.gatedContentAuthenticated,
|
||||||
authRequired: data.gatedContentAuthRequired,
|
authRequired: data.gatedContentAuthRequired,
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
// src/utils/clientUtils.ts
|
// src/utils/clientUtils.ts
|
||||||
// Client-side utilities that mirror server-side toolHelpers.ts
|
// MINIMAL utilities that don't conflict with BaseLayout.astro or env.d.ts
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CORE TOOL UTILITIES (shared between client and server)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export function createToolSlug(toolName: string): string {
|
export function createToolSlug(toolName: string): string {
|
||||||
if (!toolName || typeof toolName !== 'string') {
|
if (!toolName || typeof toolName !== 'string') {
|
||||||
@ -30,7 +34,10 @@ export function isToolHosted(tool: any): boolean {
|
|||||||
tool.projectUrl.trim() !== "";
|
tool.projectUrl.trim() !== "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consolidated Autocomplete Functionality
|
// ============================================================================
|
||||||
|
// AUTOCOMPLETE FUNCTIONALITY (keep this here since it's complex)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
interface AutocompleteOptions {
|
interface AutocompleteOptions {
|
||||||
minLength?: number;
|
minLength?: number;
|
||||||
maxResults?: number;
|
maxResults?: number;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
// src/utils/remarkVideoPlugin.ts
|
// src/utils/remarkVideoPlugin.ts - Consolidated with videoUtils
|
||||||
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, Text, Element } from 'hast';
|
import type { Root } from 'hast';
|
||||||
import { videoProcessor } from './videoUtils.js';
|
import { videoProcessor, isVideoUrl } from './videoUtils.js';
|
||||||
|
|
||||||
interface VideoConfig {
|
interface VideoConfig {
|
||||||
enableAsync?: boolean;
|
enableAsync?: boolean;
|
||||||
@ -16,15 +16,9 @@ interface VideoConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remark plugin to transform video syntax in markdown to HTML video elements
|
* CONSOLIDATED Remark plugin for video processing
|
||||||
*
|
* Uses videoProcessor singleton to avoid code duplication
|
||||||
* Supports multiple syntaxes:
|
|
||||||
* 1. Custom video directive: :::video{src="url" title="Title" controls autoplay}
|
|
||||||
* 2. Image syntax for videos: 
|
|
||||||
* 3. HTML video tags: <video src="url"></video>
|
|
||||||
* 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 = {}) => {
|
export const remarkVideoPlugin: Plugin<[VideoConfig?], Root> = (config = {}) => {
|
||||||
const {
|
const {
|
||||||
enableAsync = true,
|
enableAsync = true,
|
||||||
@ -40,53 +34,38 @@ export const remarkVideoPlugin: Plugin<[VideoConfig?], Root> = (config = {}) =>
|
|||||||
return async (tree: Root) => {
|
return async (tree: Root) => {
|
||||||
const tasks: Array<Promise<void>> = [];
|
const tasks: Array<Promise<void>> = [];
|
||||||
|
|
||||||
// :::video{...}
|
// :::video{...} syntax
|
||||||
visit(tree, 'textDirective', (node: any, index: number | undefined, parent: any) => {
|
visit(tree, 'textDirective', (node: any, index: number | undefined, parent: any) => {
|
||||||
if (node.name === 'video' && typeof index === 'number') {
|
if (node.name === 'video' && typeof index === 'number') {
|
||||||
tasks.push((async () => {
|
tasks.push(processVideoDirective(node, index, parent, defaultOptions, enableAsync));
|
||||||
const replacement = await processVideoDirectiveAsync(node, defaultOptions);
|
|
||||||
if (replacement && parent?.children) parent.children[index] = replacement;
|
|
||||||
})());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// :::video ... :::
|
// :::video ... ::: syntax
|
||||||
visit(tree, 'containerDirective', (node: any, index: number | undefined, parent: any) => {
|
visit(tree, 'containerDirective', (node: any, index: number | undefined, parent: any) => {
|
||||||
if (node.name === 'video' && typeof index === 'number') {
|
if (node.name === 'video' && typeof index === 'number') {
|
||||||
tasks.push((async () => {
|
tasks.push(processVideoDirective(node, index, parent, defaultOptions, enableAsync));
|
||||||
const replacement = await processVideoDirectiveAsync(node, defaultOptions);
|
|
||||||
if (replacement && parent?.children) parent.children[index] = replacement;
|
|
||||||
})());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 
|
//  syntax
|
||||||
visit(tree, 'image', (node: any, index: number | undefined, parent: any) => {
|
visit(tree, 'image', (node: any, index: number | undefined, parent: any) => {
|
||||||
if (isVideoUrl(node.url) && typeof index === 'number') {
|
if (isVideoUrl(node.url) && typeof index === 'number') {
|
||||||
tasks.push((async () => {
|
tasks.push(processImageAsVideo(node, index, parent, defaultOptions, enableAsync));
|
||||||
const replacement = await processImageAsVideoAsync(node, defaultOptions);
|
|
||||||
if (replacement && parent?.children) parent.children[index] = replacement;
|
|
||||||
})());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// [Title](video.mp4)
|
// [Title](video.mp4) syntax
|
||||||
visit(tree, 'link', (node: any, index: number | undefined, parent: any) => {
|
visit(tree, 'link', (node: any, index: number | undefined, parent: any) => {
|
||||||
if (isVideoUrl(node.url) && typeof index === 'number') {
|
if (isVideoUrl(node.url) && typeof index === 'number') {
|
||||||
tasks.push((async () => {
|
tasks.push(processLinkAsVideo(node, index, parent, defaultOptions, enableAsync));
|
||||||
const replacement = await processLinkAsVideoAsync(node, defaultOptions);
|
|
||||||
if (replacement && parent?.children) parent.children[index] = replacement;
|
|
||||||
})());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Raw <video ...>
|
// 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((async () => {
|
tasks.push(processHTMLVideo(node, index, parent, defaultOptions, enableAsync));
|
||||||
const replacement = await processHTMLVideoAsync(node, defaultOptions);
|
|
||||||
if (replacement && parent?.children) parent.children[index] = replacement;
|
|
||||||
})());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -94,13 +73,20 @@ export const remarkVideoPlugin: Plugin<[VideoConfig?], Root> = (config = {}) =>
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CONSOLIDATED: All processing functions use videoProcessor
|
||||||
async function processVideoDirectiveAsync(node: any, defaultOptions: VideoConfig['defaultOptions']): Promise<any | null> {
|
async function processVideoDirective(
|
||||||
|
node: any,
|
||||||
|
index: number,
|
||||||
|
parent: any,
|
||||||
|
defaultOptions: VideoConfig['defaultOptions'],
|
||||||
|
enableAsync: boolean
|
||||||
|
): Promise<void> {
|
||||||
const attributes = node.attributes || {};
|
const attributes = node.attributes || {};
|
||||||
const src = attributes.src;
|
const src = attributes.src;
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
console.warn('[VIDEO PLUGIN] Video directive missing src attribute');
|
console.warn('[VIDEO PLUGIN] Missing src in video directive');
|
||||||
return null;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aspectRatioValue = attributes.aspectRatio || attributes['aspect-ratio'] || defaultOptions?.aspectRatio;
|
const aspectRatioValue = attributes.aspectRatio || attributes['aspect-ratio'] || defaultOptions?.aspectRatio;
|
||||||
@ -108,13 +94,13 @@ async function processVideoDirectiveAsync(node: any, defaultOptions: VideoConfig
|
|||||||
const aspectRatio = (validAspectRatios.includes(aspectRatioValue as any) ? aspectRatioValue : '16:9') as '16:9' | '4:3' | '1:1';
|
const aspectRatio = (validAspectRatios.includes(aspectRatioValue as any) ? aspectRatioValue : '16:9') as '16:9' | '4:3' | '1:1';
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
controls: attributes.controls !== undefined ? attributes.controls !== 'false' : defaultOptions?.controls ?? true,
|
controls: attributes.controls !== 'false',
|
||||||
autoplay: attributes.autoplay !== undefined ? attributes.autoplay !== 'false' : defaultOptions?.autoplay ?? false,
|
autoplay: attributes.autoplay === 'true',
|
||||||
muted: attributes.muted !== undefined ? attributes.muted !== 'false' : defaultOptions?.muted ?? false,
|
muted: attributes.muted === 'true',
|
||||||
loop: attributes.loop !== undefined ? attributes.loop !== 'false' : false,
|
loop: attributes.loop === 'true',
|
||||||
preload: (attributes.preload || 'metadata') as 'none' | 'metadata' | 'auto',
|
preload: (attributes.preload || 'metadata') as 'none' | 'metadata' | 'auto',
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
showMetadata: attributes.showMetadata !== undefined ? attributes.showMetadata !== 'false' : defaultOptions?.showMetadata ?? true
|
showMetadata: attributes.showMetadata !== 'false'
|
||||||
};
|
};
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
@ -123,12 +109,23 @@ async function processVideoDirectiveAsync(node: any, defaultOptions: VideoConfig
|
|||||||
poster: attributes.poster
|
poster: attributes.poster
|
||||||
};
|
};
|
||||||
|
|
||||||
const html = await buildProcessedVideoHTML(src, metadata, options);
|
if (enableAsync) {
|
||||||
return { type: 'html', value: html };
|
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 processImageAsVideoAsync(node: any, defaultOptions: VideoConfig['defaultOptions']): Promise<any | null> {
|
async function processImageAsVideo(
|
||||||
const src = node.url;
|
node: any,
|
||||||
|
index: number,
|
||||||
|
parent: any,
|
||||||
|
defaultOptions: VideoConfig['defaultOptions'],
|
||||||
|
enableAsync: boolean
|
||||||
|
): Promise<void> {
|
||||||
const metadata = { title: node.title || node.alt, description: node.alt };
|
const metadata = { title: node.title || node.alt, description: node.alt };
|
||||||
const options = {
|
const options = {
|
||||||
controls: defaultOptions?.controls ?? true,
|
controls: defaultOptions?.controls ?? true,
|
||||||
@ -139,13 +136,24 @@ async function processImageAsVideoAsync(node: any, defaultOptions: VideoConfig['
|
|||||||
aspectRatio: (defaultOptions?.aspectRatio ?? '16:9') as '16:9' | '4:3' | '1:1',
|
aspectRatio: (defaultOptions?.aspectRatio ?? '16:9') as '16:9' | '4:3' | '1:1',
|
||||||
showMetadata: defaultOptions?.showMetadata ?? true
|
showMetadata: defaultOptions?.showMetadata ?? true
|
||||||
};
|
};
|
||||||
const html = await buildProcessedVideoHTML(src, metadata, options);
|
|
||||||
return { type: 'html', value: html };
|
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(
|
||||||
async function processLinkAsVideoAsync(node: any, defaultOptions: VideoConfig['defaultOptions']): Promise<any | null> {
|
node: any,
|
||||||
const src = node.url;
|
index: number,
|
||||||
|
parent: any,
|
||||||
|
defaultOptions: VideoConfig['defaultOptions'],
|
||||||
|
enableAsync: boolean
|
||||||
|
): Promise<void> {
|
||||||
const metadata = { title: node.title || extractTextContent(node), description: node.title };
|
const metadata = { title: node.title || extractTextContent(node), description: node.title };
|
||||||
const options = {
|
const options = {
|
||||||
controls: defaultOptions?.controls ?? true,
|
controls: defaultOptions?.controls ?? true,
|
||||||
@ -156,19 +164,31 @@ async function processLinkAsVideoAsync(node: any, defaultOptions: VideoConfig['d
|
|||||||
aspectRatio: (defaultOptions?.aspectRatio ?? '16:9') as '16:9' | '4:3' | '1:1',
|
aspectRatio: (defaultOptions?.aspectRatio ?? '16:9') as '16:9' | '4:3' | '1:1',
|
||||||
showMetadata: defaultOptions?.showMetadata ?? true
|
showMetadata: defaultOptions?.showMetadata ?? true
|
||||||
};
|
};
|
||||||
const html = await buildProcessedVideoHTML(src, metadata, options);
|
|
||||||
return { type: 'html', value: html };
|
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 processHTMLVideoAsync(node: any, defaultOptions: VideoConfig['defaultOptions']): Promise<any | null> {
|
async function processHTMLVideo(
|
||||||
|
node: any,
|
||||||
|
index: number,
|
||||||
|
parent: any,
|
||||||
|
defaultOptions: VideoConfig['defaultOptions'],
|
||||||
|
enableAsync: boolean
|
||||||
|
): Promise<void> {
|
||||||
const htmlContent = node.value || '';
|
const htmlContent = node.value || '';
|
||||||
const srcMatch = htmlContent.match(/src=["']([^"']+)["']/);
|
const srcMatch = htmlContent.match(/src=["']([^"']+)["']/);
|
||||||
if (!srcMatch) return null;
|
if (!srcMatch) return;
|
||||||
|
|
||||||
const titleMatch = htmlContent.match(/title=["']([^"']+)["']/);
|
const titleMatch = htmlContent.match(/title=["']([^"']+)["']/);
|
||||||
const posterMatch = htmlContent.match(/poster=["']([^"']+)["']/);
|
const posterMatch = htmlContent.match(/poster=["']([^"']+)["']/);
|
||||||
|
|
||||||
const src = srcMatch[1];
|
|
||||||
const metadata = { title: titleMatch?.[1], poster: posterMatch?.[1] };
|
const metadata = { title: titleMatch?.[1], poster: posterMatch?.[1] };
|
||||||
const options = {
|
const options = {
|
||||||
controls: htmlContent.includes('controls'),
|
controls: htmlContent.includes('controls'),
|
||||||
@ -180,194 +200,17 @@ async function processHTMLVideoAsync(node: any, defaultOptions: VideoConfig['def
|
|||||||
showMetadata: defaultOptions?.showMetadata ?? true
|
showMetadata: defaultOptions?.showMetadata ?? true
|
||||||
};
|
};
|
||||||
|
|
||||||
const html = await buildProcessedVideoHTML(src, metadata, options);
|
if (enableAsync) {
|
||||||
return { type: 'html', value: html };
|
const processedVideo = await videoProcessor.processVideoUrl(srcMatch[1], metadata);
|
||||||
}
|
const html = videoProcessor.generateVideoHTML(processedVideo, options);
|
||||||
|
parent.children[index] = { type: 'html', value: html };
|
||||||
function buildProcessedVideoHTML(src: string, metadata: any, options: {
|
|
||||||
controls: boolean;
|
|
||||||
autoplay: boolean;
|
|
||||||
muted: boolean;
|
|
||||||
loop: boolean;
|
|
||||||
preload: 'none' | 'metadata' | 'auto';
|
|
||||||
aspectRatio: '16:9' | '4:3' | '1:1';
|
|
||||||
showMetadata: boolean;
|
|
||||||
}): any {
|
|
||||||
// Generate a unique ID for this video
|
|
||||||
const videoId = `video-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
|
|
||||||
// Create the video HTML structure as an HTML node
|
|
||||||
const videoHTML = `
|
|
||||||
<div class="video-container aspect-${options.aspectRatio}" data-video-id="${videoId}">
|
|
||||||
<video
|
|
||||||
src="${escapeHtml(src)}"
|
|
||||||
${options.controls ? 'controls' : ''}
|
|
||||||
${options.autoplay ? 'autoplay' : ''}
|
|
||||||
${options.muted ? 'muted' : ''}
|
|
||||||
${options.loop ? 'loop' : ''}
|
|
||||||
preload="${options.preload || 'metadata'}"
|
|
||||||
${metadata.poster ? `poster="${escapeHtml(metadata.poster)}"` : ''}
|
|
||||||
${metadata.title ? `data-video-title="${escapeHtml(metadata.title)}"` : ''}
|
|
||||||
style="width: 100%; height: 100%;"
|
|
||||||
>
|
|
||||||
<p>Your browser does not support the video element.</p>
|
|
||||||
<a href="${escapeHtml(src)}">${metadata.title || 'Download Video'}</a>
|
|
||||||
</video>
|
|
||||||
${options.showMetadata && (metadata.title || metadata.description) ? `
|
|
||||||
<div class="video-metadata">
|
|
||||||
${metadata.title ? `<div class="video-title">${escapeHtml(metadata.title)}</div>` : ''}
|
|
||||||
${metadata.description ? `<div class="video-description">${escapeHtml(metadata.description)}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`.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, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced video processing with async support
|
|
||||||
export async function processVideosInContent(content: string, config: VideoConfig = {}): Promise<string> {
|
|
||||||
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 {
|
} else {
|
||||||
// Simple replacement for sync processing
|
const html = createSimpleVideoHTML(srcMatch[1], metadata, options);
|
||||||
const videoHTML = createSimpleVideoHTML(attributes.src, metadata, options);
|
parent.children[index] = { type: 'html', value: html };
|
||||||
replacements.push({
|
|
||||||
original: match[0],
|
|
||||||
replacement: videoHTML
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VIDEO PLUGIN] Error processing video directive:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply replacements
|
// SIMPLIFIED: Fallback for non-async mode
|
||||||
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<string, string> {
|
|
||||||
const attributes: Record<string, string> = {};
|
|
||||||
|
|
||||||
// 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: {
|
function createSimpleVideoHTML(src: string, metadata: any, options: {
|
||||||
controls: boolean;
|
controls: boolean;
|
||||||
autoplay: boolean;
|
autoplay: boolean;
|
||||||
@ -397,3 +240,102 @@ function createSimpleVideoHTML(src: string, metadata: any, options: {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UTILITY FUNCTIONS (moved from duplicated implementations)
|
||||||
|
function extractTextContent(node: any): string {
|
||||||
|
if (!node) return '';
|
||||||
|
if (typeof node === 'string') return node;
|
||||||
|
if (node.type === 'text') return node.value || '';
|
||||||
|
if (node.children && Array.isArray(node.children)) {
|
||||||
|
return node.children.map(extractTextContent).join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(unsafe: string): string {
|
||||||
|
if (typeof unsafe !== 'string') return '';
|
||||||
|
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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,4 +1,4 @@
|
|||||||
// src/utils/videoUtils.ts - Production version with verbose logging
|
// src/utils/videoUtils.ts - Fixed version with ORB resolution
|
||||||
import { NextcloudUploader } from './nextcloud.js';
|
import { NextcloudUploader } from './nextcloud.js';
|
||||||
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
@ -32,9 +32,9 @@ export interface ProcessedVideo {
|
|||||||
interface VideoConfig {
|
interface VideoConfig {
|
||||||
enableCaching: boolean;
|
enableCaching: boolean;
|
||||||
cacheDirectory: string;
|
cacheDirectory: string;
|
||||||
maxCacheSize: number; // in MB - only for emergency cleanup
|
maxCacheSize: number;
|
||||||
supportedFormats: string[];
|
supportedFormats: string[];
|
||||||
maxFileSize: number; // in MB
|
maxFileSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VideoProcessor {
|
export class VideoProcessor {
|
||||||
@ -53,91 +53,56 @@ export class VideoProcessor {
|
|||||||
|
|
||||||
if (process.env.MINIO_URL && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY) {
|
if (process.env.MINIO_URL && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY) {
|
||||||
this.s3Client = new S3Client({
|
this.s3Client = new S3Client({
|
||||||
endpoint: process.env.MINIO_URL, // e.g. http://127.0.0.1:9000
|
endpoint: process.env.MINIO_URL,
|
||||||
region: 'us-east-1', // default
|
region: 'us-east-1',
|
||||||
forcePathStyle: true, // works for most MinIO setups
|
forcePathStyle: true,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.MINIO_ACCESS_KEY,
|
accessKeyId: process.env.MINIO_ACCESS_KEY,
|
||||||
secretAccessKey: process.env.MINIO_SECRET_KEY
|
secretAccessKey: process.env.MINIO_SECRET_KEY
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`[VIDEO PROCESSOR] MinIO pre-signing enabled: ${process.env.MINIO_URL}`);
|
console.log(`[VIDEO] MinIO client initialized: ${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();
|
this.nextcloudUploader = new NextcloudUploader();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a video URL and return optimized sources and metadata
|
|
||||||
*/
|
|
||||||
async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
|
async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
|
||||||
console.log(`[VIDEO PROCESSOR] Processing video URL: ${url}`);
|
console.log(`[VIDEO] Processing: ${url}`);
|
||||||
console.log(`[VIDEO PROCESSOR] Provided metadata:`, metadata);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const videoSource = this.identifyVideoSource(url);
|
const videoSource = this.identifyVideoSource(url);
|
||||||
console.log(`[VIDEO PROCESSOR] Identified source type: ${videoSource.type}`);
|
console.log(`[VIDEO] Source type: ${videoSource.type}`);
|
||||||
|
|
||||||
const sources: VideoSource[] = [];
|
const sources: VideoSource[] = [];
|
||||||
|
|
||||||
// Handle different video sources
|
|
||||||
switch (videoSource.type) {
|
switch (videoSource.type) {
|
||||||
case 'nextcloud':
|
case 'nextcloud':
|
||||||
console.log(`[VIDEO PROCESSOR] Processing Nextcloud video...`);
|
|
||||||
sources.push(...await this.processNextcloudVideo(url));
|
sources.push(...await this.processNextcloudVideo(url));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 's3':
|
case 's3':
|
||||||
case 'minio':
|
case 'minio':
|
||||||
console.log(`[VIDEO PROCESSOR] Processing ${videoSource.type} video...`);
|
|
||||||
sources.push(...await this.processS3MinioVideo(url, videoSource.type));
|
sources.push(...await this.processS3MinioVideo(url, videoSource.type));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'direct':
|
case 'direct':
|
||||||
console.log(`[VIDEO PROCESSOR] Processing direct video...`);
|
|
||||||
sources.push(await this.processDirectVideo(url));
|
sources.push(await this.processDirectVideo(url));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'local':
|
case 'local':
|
||||||
console.log(`[VIDEO PROCESSOR] Processing local video...`);
|
|
||||||
sources.push(await this.processLocalVideo(url));
|
sources.push(await this.processLocalVideo(url));
|
||||||
break;
|
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);
|
const enhancedMetadata = await this.enhanceMetadata(sources[0], metadata);
|
||||||
console.log(`[VIDEO PROCESSOR] Enhanced metadata:`, enhancedMetadata);
|
|
||||||
|
|
||||||
const result = {
|
return {
|
||||||
sources,
|
sources,
|
||||||
metadata: enhancedMetadata,
|
metadata: enhancedMetadata,
|
||||||
fallbackText: this.generateFallbackText(enhancedMetadata),
|
fallbackText: this.generateFallbackText(enhancedMetadata),
|
||||||
requiresAuth: this.requiresAuthentication(videoSource.type)
|
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) {
|
} catch (error) {
|
||||||
console.error(`[VIDEO PROCESSOR] Processing failed for ${url}:`, error);
|
console.error(`[VIDEO] Processing failed: ${error.message}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sources: [],
|
sources: [],
|
||||||
metadata: { ...metadata, title: metadata.title || 'Video unavailable' },
|
metadata: { ...metadata, title: metadata.title || 'Video unavailable' },
|
||||||
@ -147,77 +112,119 @@ export class VideoProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// identifyVideoSource(url: string)
|
|
||||||
private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
|
private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
|
||||||
console.log(`[VIDEO PROCESSOR] Identifying source for URL: ${url}`);
|
// Check MinIO by endpoint first
|
||||||
|
|
||||||
// Treat links that point at your configured MinIO endpoint as 'minio'
|
|
||||||
try {
|
try {
|
||||||
const minioHost = process.env.MINIO_URL ? new URL(process.env.MINIO_URL).host : null;
|
const minioHost = process.env.MINIO_URL ? new URL(process.env.MINIO_URL).host : null;
|
||||||
const urlHost = new URL(url).host;
|
const urlHost = new URL(url).host;
|
||||||
if (minioHost && urlHost === minioHost) {
|
if (minioHost && urlHost === minioHost) {
|
||||||
console.log(`[VIDEO PROCESSOR] Detected MinIO by endpoint host match: ${minioHost}`);
|
console.log(`[VIDEO] Detected MinIO by host: ${minioHost}`);
|
||||||
return { type: 'minio', url };
|
return { type: 'minio', url };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore URL parse errors */
|
// Ignore URL parse errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pattern-based detection
|
||||||
if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
|
if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
|
||||||
console.log(`[VIDEO PROCESSOR] Detected Nextcloud share link`);
|
|
||||||
return { type: 'nextcloud', url };
|
return { type: 'nextcloud', url };
|
||||||
}
|
}
|
||||||
if (url.includes('amazonaws.com') || url.includes('.s3.')) {
|
if (url.includes('amazonaws.com') || url.includes('.s3.')) {
|
||||||
console.log(`[VIDEO PROCESSOR] Detected S3 URL`);
|
|
||||||
return { type: 's3', url };
|
return { type: 's3', url };
|
||||||
}
|
}
|
||||||
if (url.includes('minio') || url.match(/:\d+\/[^\/]+\/.*\.(mp4|webm|ogg|mov|avi)/i)) {
|
if (url.includes('minio') || this.isMinioPattern(url)) {
|
||||||
console.log(`[VIDEO PROCESSOR] Detected Minio URL`);
|
|
||||||
return { type: 'minio', url };
|
return { type: 'minio', url };
|
||||||
}
|
}
|
||||||
if (url.startsWith('/') && !url.startsWith('http')) {
|
if (url.startsWith('/') && !url.startsWith('http')) {
|
||||||
console.log(`[VIDEO PROCESSOR] Detected local file path`);
|
|
||||||
return { type: 'local', url };
|
return { type: 'local', url };
|
||||||
}
|
}
|
||||||
console.log(`[VIDEO PROCESSOR] Detected direct HTTP URL`);
|
|
||||||
return { type: 'direct', 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
|
private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
|
||||||
console.log(`[VIDEO PROCESSOR] Processing Nextcloud video: ${url}`);
|
console.log(`[VIDEO] Processing Nextcloud: ${url}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract share token from Nextcloud URL
|
|
||||||
const shareMatch = url.match(/\/s\/([^\/\?]+)/);
|
const shareMatch = url.match(/\/s\/([^\/\?]+)/);
|
||||||
if (!shareMatch) {
|
if (!shareMatch) {
|
||||||
throw new Error('Invalid Nextcloud share URL format');
|
throw new Error('Invalid Nextcloud share URL format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const shareToken = shareMatch[1];
|
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);
|
const directUrl = await this.getNextcloudDirectUrl(url, shareToken);
|
||||||
console.log(`[VIDEO PROCESSOR] Direct download URL: ${directUrl || 'Not available'}`);
|
|
||||||
|
|
||||||
const sources: VideoSource[] = [];
|
const sources: VideoSource[] = [];
|
||||||
|
|
||||||
// Always try to cache Nextcloud videos for performance
|
|
||||||
if (this.config.enableCaching && directUrl) {
|
if (this.config.enableCaching && directUrl) {
|
||||||
console.log(`[VIDEO PROCESSOR] Attempting to cache Nextcloud video...`);
|
|
||||||
const cachedSource = await this.cacheVideo(directUrl, shareToken);
|
const cachedSource = await this.cacheVideo(directUrl, shareToken);
|
||||||
if (cachedSource) {
|
if (cachedSource) {
|
||||||
console.log(`[VIDEO PROCESSOR] Successfully created cached source: ${cachedSource.url}`);
|
sources.push(cachedSource);
|
||||||
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({
|
sources.push({
|
||||||
type: 'nextcloud',
|
type: 'nextcloud',
|
||||||
url: directUrl || url,
|
url: directUrl || url,
|
||||||
@ -225,11 +232,10 @@ export class VideoProcessor {
|
|||||||
cached: false
|
cached: false
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Nextcloud processing complete. Sources: ${sources.length}`);
|
|
||||||
return sources;
|
return sources;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[VIDEO PROCESSOR] Nextcloud processing failed:', error);
|
console.error('[VIDEO] Nextcloud processing failed:', error);
|
||||||
return [{
|
return [{
|
||||||
type: 'nextcloud',
|
type: 'nextcloud',
|
||||||
url: url,
|
url: url,
|
||||||
@ -239,82 +245,24 @@ export class VideoProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getNextcloudDirectUrl(shareUrl: string, shareToken: string): Promise<string | null> {
|
private async getNextcloudDirectUrl(shareUrl: string, shareToken: string): Promise<string | null> {
|
||||||
console.log(`[VIDEO PROCESSOR] Getting direct URL for share token: ${shareToken}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = shareUrl.split('/s/')[0];
|
const baseUrl = shareUrl.split('/s/')[0];
|
||||||
const directUrl = `${baseUrl}/s/${shareToken}/download`;
|
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' });
|
const response = await fetch(directUrl, { method: 'HEAD' });
|
||||||
console.log(`[VIDEO PROCESSOR] Direct URL test response: ${response.status} ${response.statusText}`);
|
|
||||||
|
|
||||||
if (response.ok) {
|
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;
|
return directUrl;
|
||||||
} else {
|
|
||||||
console.warn(`[VIDEO PROCESSOR] Direct URL not accessible: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[VIDEO PROCESSOR] Direct URL extraction failed:', error);
|
console.warn('[VIDEO] Direct URL extraction failed:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strengthen the MinIO pre-signing to force a clean, streamable response
|
|
||||||
private async processS3MinioVideo(url: string, type: 's3' | 'minio'): Promise<VideoSource[]> {
|
|
||||||
console.log(`[VIDEO PROCESSOR] Processing ${type} video: ${url}`);
|
|
||||||
|
|
||||||
if (type === 'minio' && this.s3Client) {
|
|
||||||
const parsed = this.normalizeMinioUrl(url);
|
|
||||||
if (parsed) {
|
|
||||||
try {
|
|
||||||
const mime = this.getMimeType(url);
|
|
||||||
const cmd = new GetObjectCommand({
|
|
||||||
Bucket: parsed.bucket,
|
|
||||||
Key: parsed.key,
|
|
||||||
ResponseContentType: mime, // ensure video/* MIME
|
|
||||||
ResponseContentDisposition: 'inline', // avoid download
|
|
||||||
ResponseCacheControl: 'public, max-age=3600' // optional, helps caching
|
|
||||||
});
|
|
||||||
const signed = await getSignedUrl(this.s3Client, cmd, { expiresIn: 3600 });
|
|
||||||
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<VideoSource> {
|
private async processDirectVideo(url: string): Promise<VideoSource> {
|
||||||
console.log(`[VIDEO PROCESSOR] Processing direct video: ${url}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'direct',
|
type: 'direct',
|
||||||
url,
|
url,
|
||||||
@ -323,8 +271,6 @@ export class VideoProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async processLocalVideo(url: string): Promise<VideoSource> {
|
private async processLocalVideo(url: string): Promise<VideoSource> {
|
||||||
console.log(`[VIDEO PROCESSOR] Processing local video: ${url}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'local',
|
type: 'local',
|
||||||
url,
|
url,
|
||||||
@ -334,32 +280,22 @@ export class VideoProcessor {
|
|||||||
|
|
||||||
private async cacheVideo(sourceUrl: string, identifier: string): Promise<VideoSource | null> {
|
private async cacheVideo(sourceUrl: string, identifier: string): Promise<VideoSource | null> {
|
||||||
if (!this.config.enableCaching) {
|
if (!this.config.enableCaching) {
|
||||||
console.log(`[VIDEO PROCESSOR] Caching disabled, skipping cache for: ${identifier}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Starting cache process for: ${identifier}`);
|
|
||||||
console.log(`[VIDEO PROCESSOR] Source URL: ${sourceUrl}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fs = await import('fs/promises');
|
const fs = await import('fs/promises');
|
||||||
const path = await import('path');
|
const path = await import('path');
|
||||||
const crypto = await import('crypto');
|
const crypto = await import('crypto');
|
||||||
|
|
||||||
// Generate cache filename
|
|
||||||
const urlHash = crypto.createHash('sha256').update(sourceUrl).digest('hex').substring(0, 16);
|
const urlHash = crypto.createHash('sha256').update(sourceUrl).digest('hex').substring(0, 16);
|
||||||
const extension = path.extname(new URL(sourceUrl).pathname) || '.mp4';
|
const extension = path.extname(new URL(sourceUrl).pathname) || '.mp4';
|
||||||
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);
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Cache filename: ${cacheFilename}`);
|
|
||||||
console.log(`[VIDEO PROCESSOR] Cache path: ${cachePath}`);
|
|
||||||
|
|
||||||
// Check if already cached (no expiration check - cache indefinitely)
|
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(cachePath);
|
const stat = await fs.stat(cachePath);
|
||||||
const sizeMB = Math.round(stat.size / 1024 / 1024);
|
console.log(`[VIDEO] Using cached: ${cacheFilename}`);
|
||||||
console.log(`[VIDEO PROCESSOR] Found existing cached video: ${cacheFilename} (${sizeMB}MB, created: ${stat.birthtime || stat.ctime})`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'local',
|
type: 'local',
|
||||||
@ -367,49 +303,30 @@ export class VideoProcessor {
|
|||||||
originalUrl: sourceUrl,
|
originalUrl: sourceUrl,
|
||||||
cached: true
|
cached: true
|
||||||
};
|
};
|
||||||
} catch (statError) {
|
} catch {
|
||||||
console.log(`[VIDEO PROCESSOR] No existing cache found for ${cacheFilename}, proceeding with download`);
|
// File doesn't exist, proceed 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 });
|
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);
|
const response = await fetch(sourceUrl);
|
||||||
console.log(`[VIDEO PROCESSOR] Download response: ${response.status} ${response.statusText}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file size
|
|
||||||
const contentLength = parseInt(response.headers.get('content-length') || '0');
|
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) {
|
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`);
|
console.warn(`[VIDEO] File too large for caching: ${Math.round(contentLength / 1024 / 1024)}MB`);
|
||||||
return null; // Don't cache, but don't fail either
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream to file
|
|
||||||
console.log(`[VIDEO PROCESSOR] Downloading video content...`);
|
|
||||||
const buffer = await response.arrayBuffer();
|
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));
|
await fs.writeFile(cachePath, new Uint8Array(buffer));
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Successfully cached: ${cacheFilename} (${actualSizeMB}MB)`);
|
console.log(`[VIDEO] Cached: ${cacheFilename}`);
|
||||||
|
|
||||||
// Only clean up if we're getting critically low on space
|
|
||||||
await this.emergencyCleanupIfNeeded();
|
await this.emergencyCleanupIfNeeded();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -420,22 +337,20 @@ export class VideoProcessor {
|
|||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[VIDEO PROCESSOR] Caching failed for ${identifier}:`, error);
|
console.error(`[VIDEO] Caching failed: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async emergencyCleanupIfNeeded(): Promise<void> {
|
private async emergencyCleanupIfNeeded(): Promise<void> {
|
||||||
console.log(`[VIDEO PROCESSOR] Checking if emergency cleanup is needed...`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fs = await import('fs/promises');
|
const fs = await import('fs/promises');
|
||||||
const path = await import('path');
|
const path = await import('path');
|
||||||
|
|
||||||
const files = await fs.readdir(this.config.cacheDirectory);
|
const files = await fs.readdir(this.config.cacheDirectory);
|
||||||
const videoFiles = files.filter(f => this.config.supportedFormats.some(fmt => f.toLowerCase().endsWith(`.${fmt}`)));
|
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(
|
const fileStats = await Promise.all(
|
||||||
videoFiles.map(async (file) => {
|
videoFiles.map(async (file) => {
|
||||||
@ -445,57 +360,34 @@ export class VideoProcessor {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate total cache size
|
const totalSize = fileStats.reduce((sum, { stat }) => sum + stat.size, 0);
|
||||||
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 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) {
|
if (totalSize > maxBytes) {
|
||||||
console.warn(`[VIDEO PROCESSOR] Cache size (${totalSizeMB}MB) exceeds limit (${maxSizeMB}MB). Starting emergency cleanup...`);
|
console.warn(`[VIDEO] Cache cleanup needed: ${Math.round(totalSize / 1024 / 1024)}MB > ${this.config.maxCacheSize}MB`);
|
||||||
|
|
||||||
// Sort by access time (least recently accessed first)
|
|
||||||
fileStats.sort((a, b) => a.stat.atime.getTime() - b.stat.atime.getTime());
|
fileStats.sort((a, b) => a.stat.atime.getTime() - b.stat.atime.getTime());
|
||||||
|
|
||||||
const targetSize = maxBytes * 0.8; // Clean to 80% of limit
|
const targetSize = maxBytes * 0.8;
|
||||||
let removedCount = 0;
|
let currentSize = totalSize;
|
||||||
let removedSize = 0;
|
|
||||||
|
|
||||||
// Remove oldest files until we're under the target
|
|
||||||
for (const { file, filePath, stat } of fileStats) {
|
for (const { file, filePath, stat } of fileStats) {
|
||||||
if (totalSize <= targetSize) break;
|
if (currentSize <= 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);
|
await fs.unlink(filePath);
|
||||||
totalSize -= stat.size;
|
currentSize -= stat.size;
|
||||||
removedSize += stat.size;
|
console.log(`[VIDEO] Cleaned up: ${file}`);
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('[VIDEO PROCESSOR] Emergency cleanup failed:', 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> {
|
||||||
console.log(`[VIDEO PROCESSOR] Enhancing metadata for source type: ${source.type}`);
|
|
||||||
|
|
||||||
const metadata: VideoMetadata = { ...providedMetadata };
|
const metadata: VideoMetadata = { ...providedMetadata };
|
||||||
|
|
||||||
// Try to extract metadata from file if possible
|
|
||||||
if (source.type === 'local' && source.cached) {
|
if (source.type === 'local' && source.cached) {
|
||||||
try {
|
try {
|
||||||
const path = await import('path');
|
const path = await import('path');
|
||||||
@ -503,26 +395,21 @@ export class VideoProcessor {
|
|||||||
|
|
||||||
if (!metadata.format) {
|
if (!metadata.format) {
|
||||||
metadata.format = ext;
|
metadata.format = ext;
|
||||||
console.log(`[VIDEO PROCESSOR] Detected format from extension: ${ext}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata.title) {
|
if (!metadata.title) {
|
||||||
metadata.title = path.basename(source.url, path.extname(source.url));
|
metadata.title = path.basename(source.url, path.extname(source.url));
|
||||||
console.log(`[VIDEO PROCESSOR] Generated title from filename: ${metadata.title}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[VIDEO PROCESSOR] Metadata extraction failed:', error);
|
console.warn('[VIDEO] Metadata extraction failed:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set defaults
|
|
||||||
if (!metadata.title) {
|
if (!metadata.title) {
|
||||||
metadata.title = 'Embedded Video';
|
metadata.title = 'Embedded Video';
|
||||||
console.log(`[VIDEO PROCESSOR] Using default title: ${metadata.title}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Final metadata:`, metadata);
|
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -537,19 +424,13 @@ export class VideoProcessor {
|
|||||||
fallback += ` (${Math.floor(metadata.duration / 60)}:${(metadata.duration % 60).toString().padStart(2, '0')})`;
|
fallback += ` (${Math.floor(metadata.duration / 60)}:${(metadata.duration % 60).toString().padStart(2, '0')})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Generated fallback text: ${fallback}`);
|
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
private requiresAuthentication(sourceType: VideoSource['type']): boolean {
|
private requiresAuthentication(sourceType: VideoSource['type']): boolean {
|
||||||
const requiresAuth = sourceType === 'nextcloud';
|
return sourceType === 'nextcloud';
|
||||||
console.log(`[VIDEO PROCESSOR] Authentication required for ${sourceType}: ${requiresAuth}`);
|
|
||||||
return requiresAuth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate HTML for video embedding in markdown
|
|
||||||
*/
|
|
||||||
generateVideoHTML(processedVideo: ProcessedVideo, options: {
|
generateVideoHTML(processedVideo: ProcessedVideo, options: {
|
||||||
controls?: boolean;
|
controls?: boolean;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
@ -562,9 +443,6 @@ export class VideoProcessor {
|
|||||||
showMetadata?: boolean;
|
showMetadata?: boolean;
|
||||||
} = {}): string {
|
} = {}): string {
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Generating HTML for video with ${processedVideo.sources.length} sources`);
|
|
||||||
console.log(`[VIDEO PROCESSOR] HTML options:`, options);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
controls = true,
|
controls = true,
|
||||||
autoplay = false,
|
autoplay = false,
|
||||||
@ -576,7 +454,6 @@ export class VideoProcessor {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (processedVideo.sources.length === 0) {
|
if (processedVideo.sources.length === 0) {
|
||||||
console.warn(`[VIDEO PROCESSOR] No sources available, generating error HTML`);
|
|
||||||
return `
|
return `
|
||||||
<div class="video-container aspect-${aspectRatio}">
|
<div class="video-container aspect-${aspectRatio}">
|
||||||
<div class="video-error">
|
<div class="video-error">
|
||||||
@ -590,43 +467,30 @@ export class VideoProcessor {
|
|||||||
const primarySource = processedVideo.sources[0];
|
const primarySource = processedVideo.sources[0];
|
||||||
const { metadata } = processedVideo;
|
const { metadata } = processedVideo;
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Primary source: ${primarySource.type} (cached: ${primarySource.cached})`);
|
// FIXED: Only add crossorigin for trusted sources that actually need it
|
||||||
console.log(`[VIDEO PROCESSOR] Primary source URL: ${primarySource.url}`);
|
const needsCrossOrigin = this.shouldUseCrossOrigin(primarySource);
|
||||||
|
|
||||||
const needsCrossOrigin = processedVideo.sources.some(s =>
|
const videoAttributes = [
|
||||||
/^https?:\/\//i.test(s.url) && !this.isSameOrigin(s.url)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (needsCrossOrigin==true){
|
|
||||||
console.log('FUCK CROSS ORIGIN')
|
|
||||||
}
|
|
||||||
|
|
||||||
// In generateVideoHTML(...), when building videoAttributes array
|
|
||||||
let videoAttributes = [
|
|
||||||
controls ? 'controls' : '',
|
controls ? 'controls' : '',
|
||||||
autoplay ? 'autoplay' : '',
|
autoplay ? 'autoplay' : '',
|
||||||
muted ? 'muted' : '',
|
muted ? 'muted' : '',
|
||||||
loop ? 'loop' : '',
|
loop ? 'loop' : '',
|
||||||
needsCrossOrigin ? 'crossorigin="anonymous"' : '',
|
needsCrossOrigin ? 'crossorigin="anonymous"' : '',
|
||||||
`preload="${preload}"`,
|
`preload="${preload}"`,
|
||||||
metadata.poster ? `poster="${metadata.poster}"` : '',
|
metadata.poster ? `poster="${this.escapeHtml(metadata.poster)}"` : '',
|
||||||
`data-video-title="${metadata.title || 'Embedded Video'}"`
|
`data-video-title="${this.escapeHtml(metadata.title || 'Embedded Video')}"`
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Video attributes: ${videoAttributes}`);
|
|
||||||
|
|
||||||
const sourceTags = processedVideo.sources
|
const sourceTags = processedVideo.sources
|
||||||
.map(source => {
|
.map(source => {
|
||||||
const mimeType = this.getMimeType(source.url);
|
const mimeType = this.getMimeType(source.url);
|
||||||
console.log(`[VIDEO PROCESSOR] Source tag: ${source.url} (${mimeType})`);
|
return `<source src="${this.escapeHtml(source.url)}" type="${mimeType}">`;
|
||||||
return `<source src="${source.url}" type="${mimeType}">`;
|
|
||||||
})
|
})
|
||||||
.join('\n ');
|
.join('\n ');
|
||||||
|
|
||||||
const metadataHTML = showMetadata && (metadata.title || metadata.duration || metadata.format) ? `
|
const metadataHTML = showMetadata && (metadata.title || metadata.duration || metadata.format) ? `
|
||||||
<div class="video-metadata">
|
<div class="video-metadata">
|
||||||
${metadata.title ? `<div class="video-title">${metadata.title}</div>` : ''}
|
${metadata.title ? `<div class="video-title">${this.escapeHtml(metadata.title)}</div>` : ''}
|
||||||
<div class="video-info">
|
<div class="video-info">
|
||||||
${metadata.duration ? `<div class="video-duration">⏱️ ${this.formatDuration(metadata.duration)}</div>` : ''}
|
${metadata.duration ? `<div class="video-duration">⏱️ ${this.formatDuration(metadata.duration)}</div>` : ''}
|
||||||
${metadata.format ? `<div class="video-format">🎥 ${metadata.format.toUpperCase()}</div>` : ''}
|
${metadata.format ? `<div class="video-format">🎥 ${metadata.format.toUpperCase()}</div>` : ''}
|
||||||
@ -635,7 +499,7 @@ export class VideoProcessor {
|
|||||||
</div>
|
</div>
|
||||||
` : '';
|
` : '';
|
||||||
|
|
||||||
const finalHTML = `
|
return `
|
||||||
<div class="video-container aspect-${aspectRatio}">
|
<div class="video-container aspect-${aspectRatio}">
|
||||||
<video ${videoAttributes}>
|
<video ${videoAttributes}>
|
||||||
${sourceTags}
|
${sourceTags}
|
||||||
@ -644,23 +508,44 @@ export class VideoProcessor {
|
|||||||
${metadataHTML}
|
${metadataHTML}
|
||||||
</div>
|
</div>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
console.log(`[VIDEO PROCESSOR] Generated HTML (${finalHTML.length} chars) with metadata: ${!!metadataHTML}`);
|
|
||||||
|
|
||||||
return finalHTML;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSameOrigin(u: string): boolean {
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
const site = process.env.PUBLIC_SITE_ORIGIN || '';
|
const url = new URL(source.url);
|
||||||
if (!site) return false;
|
const trustedDomains = ['youtube.com', 'youtu.be', 'vimeo.com'];
|
||||||
return new URL(u).host === new URL(site).host;
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make getMimeType() robust against presigned URLs with query strings
|
|
||||||
private getMimeType(url: string): string {
|
private getMimeType(url: string): string {
|
||||||
let extension: string | undefined;
|
let extension: string | undefined;
|
||||||
try {
|
try {
|
||||||
@ -669,6 +554,7 @@ export class VideoProcessor {
|
|||||||
} catch {
|
} catch {
|
||||||
extension = url.split('?')[0].split('.').pop()?.toLowerCase();
|
extension = url.split('?')[0].split('.').pop()?.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeTypes: Record<string, string> = {
|
const mimeTypes: Record<string, string> = {
|
||||||
mp4: 'video/mp4',
|
mp4: 'video/mp4',
|
||||||
webm: 'video/webm',
|
webm: 'video/webm',
|
||||||
@ -677,11 +563,9 @@ export class VideoProcessor {
|
|||||||
avi: 'video/x-msvideo',
|
avi: 'video/x-msvideo',
|
||||||
m4v: 'video/m4v'
|
m4v: 'video/m4v'
|
||||||
};
|
};
|
||||||
const mimeType = (extension && mimeTypes[extension]) ? mimeTypes[extension] : 'video/mp4';
|
|
||||||
console.log(`[VIDEO PROCESSOR] MIME type for extension '${extension}': ${mimeType}`);
|
|
||||||
return mimeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return (extension && mimeTypes[extension]) || 'video/mp4';
|
||||||
|
}
|
||||||
|
|
||||||
private formatDuration(seconds: number): string {
|
private formatDuration(seconds: number): string {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
@ -701,12 +585,23 @@ export class VideoProcessor {
|
|||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private escapeHtml(unsafe: string): string {
|
||||||
|
if (typeof unsafe !== 'string') return '';
|
||||||
|
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const videoProcessor = new VideoProcessor();
|
export const videoProcessor = new VideoProcessor();
|
||||||
|
|
||||||
// Production utility functions for markdown integration
|
// Utility functions for markdown integration
|
||||||
export async function processVideoEmbed(url: string, metadata?: Partial<VideoMetadata>, options?: {
|
export async function processVideoEmbed(url: string, metadata?: Partial<VideoMetadata>, options?: {
|
||||||
controls?: boolean;
|
controls?: boolean;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
@ -716,20 +611,12 @@ export async function processVideoEmbed(url: string, metadata?: Partial<VideoMet
|
|||||||
aspectRatio?: '16:9' | '4:3' | '1:1';
|
aspectRatio?: '16:9' | '4:3' | '1:1';
|
||||||
showMetadata?: boolean;
|
showMetadata?: boolean;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
console.log(`[VIDEO EMBED] Processing embed for: ${url}`);
|
|
||||||
|
|
||||||
const processedVideo = await videoProcessor.processVideoUrl(url, metadata);
|
const processedVideo = await videoProcessor.processVideoUrl(url, metadata);
|
||||||
const html = videoProcessor.generateVideoHTML(processedVideo, options);
|
return videoProcessor.generateVideoHTML(processedVideo, options);
|
||||||
|
|
||||||
console.log(`[VIDEO EMBED] Generated HTML embed for: ${url}`);
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVideoUrl(url: string): boolean {
|
export function isVideoUrl(url: string): boolean {
|
||||||
const videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi'];
|
const videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi'];
|
||||||
const extension = url.split('.').pop()?.toLowerCase();
|
const extension = url.split('.').pop()?.toLowerCase();
|
||||||
const isVideo = videoExtensions.includes(extension || '');
|
return videoExtensions.includes(extension || '');
|
||||||
|
|
||||||
console.log(`[VIDEO EMBED] URL ${url} is video: ${isVideo}`);
|
|
||||||
return isVideo;
|
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user