This commit is contained in:
overcuriousity 2025-08-12 16:06:29 +02:00
parent f159f904f0
commit 2d920391ad
3 changed files with 140 additions and 125 deletions

View File

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

View File

@ -24,6 +24,7 @@ interface VideoConfig {
* 3. HTML video tags: <video src="url"></video> * 3. HTML video tags: <video src="url"></video>
* 4. Link syntax with video: [Video Title](url.mp4) * 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,
@ -37,84 +38,75 @@ export const remarkVideoPlugin: Plugin<[VideoConfig?], Root> = (config = {}) =>
} = config; } = config;
return async (tree: Root) => { return async (tree: Root) => {
const videoNodes: Array<{ node: any; parent: any; index: number; replacement: any }> = []; const tasks: Array<Promise<void>> = [];
// Find video directives (:::video{...}) // :::video{...}
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') {
const videoNode = processVideoDirective(node, defaultOptions); tasks.push((async () => {
if (videoNode) { const replacement = await processVideoDirectiveAsync(node, defaultOptions);
videoNodes.push({ node, parent, index, replacement: videoNode }); if (replacement && parent?.children) parent.children[index] = replacement;
} })());
} }
}); });
// Find container directives (:::video ... :::) // :::video ... :::
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') {
const videoNode = processVideoDirective(node, defaultOptions); tasks.push((async () => {
if (videoNode) { const replacement = await processVideoDirectiveAsync(node, defaultOptions);
videoNodes.push({ node, parent, index, replacement: videoNode }); 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) => { visit(tree, 'image', (node: any, index: number | undefined, parent: any) => {
if (isVideoUrl(node.url) && typeof index === 'number') { if (isVideoUrl(node.url) && typeof index === 'number') {
const videoNode = processImageAsVideo(node, defaultOptions); tasks.push((async () => {
if (videoNode) { const replacement = await processImageAsVideoAsync(node, defaultOptions);
videoNodes.push({ node, parent, index, replacement: videoNode }); 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) => { visit(tree, 'link', (node: any, index: number | undefined, parent: any) => {
if (isVideoUrl(node.url) && typeof index === 'number') { if (isVideoUrl(node.url) && typeof index === 'number') {
const videoNode = processLinkAsVideo(node, defaultOptions); tasks.push((async () => {
if (videoNode) { const replacement = await processLinkAsVideoAsync(node, defaultOptions);
videoNodes.push({ node, parent, index, replacement: videoNode }); if (replacement && parent?.children) parent.children[index] = replacement;
} })());
} }
}); });
// Process HTML video tags in the tree // Raw <video ...>
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') {
const videoNode = processHTMLVideo(node, defaultOptions); tasks.push((async () => {
if (videoNode) { const replacement = await processHTMLVideoAsync(node, defaultOptions);
videoNodes.push({ node, parent, index, replacement: videoNode }); if (replacement && parent?.children) parent.children[index] = replacement;
} })());
} }
}); });
// Replace all found video nodes await Promise.all(tasks);
for (const { parent, index, replacement } of videoNodes.reverse()) {
if (parent && parent.children && Array.isArray(parent.children)) {
parent.children[index] = replacement;
}
}
}; };
}; };
function processVideoDirective(node: any, defaultOptions: VideoConfig['defaultOptions']): any | null {
async function processVideoDirectiveAsync(node: any, defaultOptions: VideoConfig['defaultOptions']): Promise<any | null> {
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] Video directive missing src attribute');
return null; return null;
} }
// Validate and normalize aspect ratio
const aspectRatioValue = attributes.aspectRatio || attributes['aspect-ratio'] || defaultOptions?.aspectRatio; const aspectRatioValue = attributes.aspectRatio || attributes['aspect-ratio'] || defaultOptions?.aspectRatio;
const validAspectRatios = ['16:9', '4:3', '1:1'] as const; const validAspectRatios = ['16:9', '4:3', '1:1'] as const;
const aspectRatio = validAspectRatios.includes(aspectRatioValue as any) const aspectRatio = (validAspectRatios.includes(aspectRatioValue as any) ? aspectRatioValue : '16:9') as '16:9' | '4:3' | '1:1';
? aspectRatioValue as '16:9' | '4:3' | '1:1'
: '16:9';
// Extract options from attributes
const options = { const options = {
controls: attributes.controls !== undefined ? attributes.controls !== 'false' : defaultOptions?.controls ?? true, controls: attributes.controls !== undefined ? attributes.controls !== 'false' : defaultOptions?.controls ?? true,
autoplay: attributes.autoplay !== undefined ? attributes.autoplay !== 'false' : defaultOptions?.autoplay ?? false, autoplay: attributes.autoplay !== undefined ? attributes.autoplay !== 'false' : defaultOptions?.autoplay ?? false,
@ -131,87 +123,68 @@ function processVideoDirective(node: any, defaultOptions: VideoConfig['defaultOp
poster: attributes.poster poster: attributes.poster
}; };
return createVideoElement(src, metadata, options); const html = await buildProcessedVideoHTML(src, metadata, options);
return { type: 'html', value: html };
} }
function processImageAsVideo(node: any, defaultOptions: VideoConfig['defaultOptions']): any | null { async function processImageAsVideoAsync(node: any, defaultOptions: VideoConfig['defaultOptions']): Promise<any | null> {
const src = node.url; const src = node.url;
const metadata = { const metadata = { title: node.title || node.alt, description: node.alt };
title: node.title || node.alt,
description: node.alt
};
// Ensure aspectRatio is properly typed
const options = { const options = {
controls: defaultOptions?.controls ?? true, controls: defaultOptions?.controls ?? true,
autoplay: defaultOptions?.autoplay ?? false, autoplay: defaultOptions?.autoplay ?? false,
muted: defaultOptions?.muted ?? false, muted: defaultOptions?.muted ?? false,
loop: false, loop: false,
preload: 'metadata' as const, preload: 'metadata' as const,
aspectRatio: defaultOptions?.aspectRatio ?? '16:9' as const, 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 createVideoElement(src, metadata, options); return { type: 'html', value: html };
} }
function processLinkAsVideo(node: any, defaultOptions: VideoConfig['defaultOptions']): any | null {
const src = node.url;
const metadata = {
title: node.title || extractTextContent(node),
description: node.title
};
// Ensure aspectRatio is properly typed async function processLinkAsVideoAsync(node: any, defaultOptions: VideoConfig['defaultOptions']): Promise<any | null> {
const src = node.url;
const metadata = { title: node.title || extractTextContent(node), description: node.title };
const options = { const options = {
controls: defaultOptions?.controls ?? true, controls: defaultOptions?.controls ?? true,
autoplay: defaultOptions?.autoplay ?? false, autoplay: defaultOptions?.autoplay ?? false,
muted: defaultOptions?.muted ?? false, muted: defaultOptions?.muted ?? false,
loop: false, loop: false,
preload: 'metadata' as const, preload: 'metadata' as const,
aspectRatio: defaultOptions?.aspectRatio ?? '16:9' as const, aspectRatio: (defaultOptions?.aspectRatio ?? '16:9') as '16:9' | '4:3' | '1:1',
showMetadata: defaultOptions?.showMetadata ?? true
};
const html = await buildProcessedVideoHTML(src, metadata, options);
return { type: 'html', value: html };
}
async function processHTMLVideoAsync(node: any, defaultOptions: VideoConfig['defaultOptions']): Promise<any | null> {
const htmlContent = node.value || '';
const srcMatch = htmlContent.match(/src=["']([^"']+)["']/);
if (!srcMatch) return null;
const titleMatch = htmlContent.match(/title=["']([^"']+)["']/);
const posterMatch = htmlContent.match(/poster=["']([^"']+)["']/);
const src = srcMatch[1];
const metadata = { title: titleMatch?.[1], poster: posterMatch?.[1] };
const options = {
controls: htmlContent.includes('controls'),
autoplay: htmlContent.includes('autoplay'),
muted: htmlContent.includes('muted'),
loop: htmlContent.includes('loop'),
preload: 'metadata' as const,
aspectRatio: (defaultOptions?.aspectRatio ?? '16:9') as '16:9' | '4:3' | '1:1',
showMetadata: defaultOptions?.showMetadata ?? true showMetadata: defaultOptions?.showMetadata ?? true
}; };
return createVideoElement(src, metadata, options); const html = await buildProcessedVideoHTML(src, metadata, options);
return { type: 'html', value: html };
} }
function processHTMLVideo(node: any, defaultOptions: VideoConfig['defaultOptions']): any | null { function buildProcessedVideoHTML(src: string, metadata: any, options: {
try {
// Parse HTML to extract video attributes
const htmlContent = node.value;
const srcMatch = htmlContent.match(/src=["']([^"']+)["']/);
const titleMatch = htmlContent.match(/title=["']([^"']+)["']/);
const posterMatch = htmlContent.match(/poster=["']([^"']+)["']/);
if (!srcMatch) {
return null;
}
const src = srcMatch[1];
const metadata = {
title: titleMatch?.[1],
poster: posterMatch?.[1]
};
const options = {
controls: htmlContent.includes('controls'),
autoplay: htmlContent.includes('autoplay'),
muted: htmlContent.includes('muted'),
loop: htmlContent.includes('loop'),
preload: 'metadata' as const,
aspectRatio: defaultOptions?.aspectRatio ?? '16:9' as const,
showMetadata: defaultOptions?.showMetadata ?? true
};
return createVideoElement(src, metadata, options);
} catch (error) {
console.warn('[VIDEO PLUGIN] Failed to process HTML video:', error);
return null;
}
}
function createVideoElement(src: string, metadata: any, options: {
controls: boolean; controls: boolean;
autoplay: boolean; autoplay: boolean;
muted: boolean; muted: boolean;

View File

@ -147,37 +147,42 @@ 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}`); console.log(`[VIDEO PROCESSOR] Identifying source for URL: ${url}`);
// Nextcloud share links // Treat links that point at your configured MinIO endpoint as 'minio'
try {
const minioHost = process.env.MINIO_URL ? new URL(process.env.MINIO_URL).host : null;
const urlHost = new URL(url).host;
if (minioHost && urlHost === minioHost) {
console.log(`[VIDEO PROCESSOR] Detected MinIO by endpoint host match: ${minioHost}`);
return { type: 'minio', url };
}
} catch {
/* ignore URL parse errors */
}
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`); console.log(`[VIDEO PROCESSOR] Detected Nextcloud share link`);
return { type: 'nextcloud', url }; return { type: 'nextcloud', url };
} }
// S3 URLs
if (url.includes('amazonaws.com') || url.includes('.s3.')) { if (url.includes('amazonaws.com') || url.includes('.s3.')) {
console.log(`[VIDEO PROCESSOR] Detected S3 URL`); console.log(`[VIDEO PROCESSOR] Detected S3 URL`);
return { type: 's3', url }; return { type: 's3', url };
} }
// Minio URLs (common patterns)
if (url.includes('minio') || url.match(/:\d+\/[^\/]+\/.*\.(mp4|webm|ogg|mov|avi)/i)) { if (url.includes('minio') || url.match(/:\d+\/[^\/]+\/.*\.(mp4|webm|ogg|mov|avi)/i)) {
console.log(`[VIDEO PROCESSOR] Detected Minio URL`); console.log(`[VIDEO PROCESSOR] Detected Minio URL`);
return { type: 'minio', url }; return { type: 'minio', url };
} }
// Local files (relative paths)
if (url.startsWith('/') && !url.startsWith('http')) { if (url.startsWith('/') && !url.startsWith('http')) {
console.log(`[VIDEO PROCESSOR] Detected local file path`); console.log(`[VIDEO PROCESSOR] Detected local file path`);
return { type: 'local', url }; return { type: 'local', url };
} }
// Direct HTTP(S) URLs
console.log(`[VIDEO PROCESSOR] Detected direct HTTP URL`); console.log(`[VIDEO PROCESSOR] Detected direct HTTP URL`);
return { type: 'direct', url }; return { type: 'direct', url };
} }
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 PROCESSOR] Processing Nextcloud video: ${url}`);
@ -262,7 +267,7 @@ export class VideoProcessor {
} }
} }
// Replace processS3MinioVideo() with: // Strengthen the MinIO pre-signing to force a clean, streamable response
private async processS3MinioVideo(url: string, type: 's3' | 'minio'): Promise<VideoSource[]> { private async processS3MinioVideo(url: string, type: 's3' | 'minio'): Promise<VideoSource[]> {
console.log(`[VIDEO PROCESSOR] Processing ${type} video: ${url}`); console.log(`[VIDEO PROCESSOR] Processing ${type} video: ${url}`);
@ -270,18 +275,25 @@ export class VideoProcessor {
const parsed = this.normalizeMinioUrl(url); const parsed = this.normalizeMinioUrl(url);
if (parsed) { if (parsed) {
try { try {
const cmd = new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key }); const mime = this.getMimeType(url);
const signed = await getSignedUrl(this.s3Client, cmd, { expiresIn: 3600 }); // 1h 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 }]; return [{ type: 'minio', url: signed, originalUrl: url, cached: false }];
} catch (e) { } catch (e) {
console.warn('[VIDEO PROCESSOR] MinIO pre-sign failed:', e); console.warn('[VIDEO PROCESSOR] MinIO pre-sign failed:', e);
} }
} }
} }
return [{ type, url, cached: false }]; return [{ type, url, cached: false }];
} }
private normalizeMinioUrl(inputUrl: string): { bucket: string; key: string } | null { private normalizeMinioUrl(inputUrl: string): { bucket: string; key: string } | null {
try { try {
const u = new URL(inputUrl); const u = new URL(inputUrl);
@ -580,16 +592,27 @@ export class VideoProcessor {
console.log(`[VIDEO PROCESSOR] Primary source: ${primarySource.type} (cached: ${primarySource.cached})`); console.log(`[VIDEO PROCESSOR] Primary source: ${primarySource.type} (cached: ${primarySource.cached})`);
console.log(`[VIDEO PROCESSOR] Primary source URL: ${primarySource.url}`); console.log(`[VIDEO PROCESSOR] Primary source URL: ${primarySource.url}`);
const needsCrossOrigin = processedVideo.sources.some(s =>
/^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 = [ let videoAttributes = [
controls ? 'controls' : '', controls ? 'controls' : '',
autoplay ? 'autoplay' : '', autoplay ? 'autoplay' : '',
muted ? 'muted' : '', muted ? 'muted' : '',
loop ? 'loop' : '', loop ? 'loop' : '',
needsCrossOrigin ? 'crossorigin="anonymous"' : '',
`preload="${preload}"`, `preload="${preload}"`,
metadata.poster ? `poster="${metadata.poster}"` : '', metadata.poster ? `poster="${metadata.poster}"` : '',
`data-video-title="${metadata.title || 'Embedded Video'}"` `data-video-title="${metadata.title || 'Embedded Video'}"`
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
console.log(`[VIDEO PROCESSOR] Video attributes: ${videoAttributes}`); console.log(`[VIDEO PROCESSOR] Video attributes: ${videoAttributes}`);
@ -626,24 +649,40 @@ export class VideoProcessor {
return finalHTML; return finalHTML;
} }
private getMimeType(url: string): string { private isSameOrigin(u: string): boolean {
const extension = url.split('.').pop()?.toLowerCase(); try {
const mimeTypes: Record<string, string> = { const site = process.env.PUBLIC_SITE_ORIGIN || '';
'mp4': 'video/mp4', if (!site) return false;
'webm': 'video/webm', return new URL(u).host === new URL(site).host;
'ogg': 'video/ogg', } catch {
'mov': 'video/quicktime', return false;
'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;
} }
// Make getMimeType() robust against presigned URLs with query strings
private getMimeType(url: string): string {
let extension: string | undefined;
try {
const pathname = new URL(url).pathname;
extension = pathname.split('.').pop()?.toLowerCase();
} catch {
extension = url.split('?')[0].split('.').pop()?.toLowerCase();
}
const mimeTypes: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
m4v: 'video/m4v'
};
const mimeType = (extension && mimeTypes[extension]) ? mimeTypes[extension] : 'video/mp4';
console.log(`[VIDEO PROCESSOR] MIME type for extension '${extension}': ${mimeType}`);
return mimeType;
}
private formatDuration(seconds: number): string { private formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);