videos #17

Merged
mstoeck3 merged 7 commits from videos into main 2025-08-12 20:35:06 +00:00
3 changed files with 140 additions and 125 deletions
Showing only changes of commit 2d920391ad - Show all commits

View File

@ -18,6 +18,9 @@ sections:
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!

View File

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

View File

@ -147,38 +147,43 @@ export class VideoProcessor {
}
}
// identifyVideoSource(url: string)
private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
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')) {
console.log(`[VIDEO PROCESSOR] Detected Nextcloud share link`);
return { type: 'nextcloud', url };
}
// S3 URLs
if (url.includes('amazonaws.com') || url.includes('.s3.')) {
console.log(`[VIDEO PROCESSOR] Detected S3 URL`);
return { type: 's3', url };
}
// Minio URLs (common patterns)
if (url.includes('minio') || url.match(/:\d+\/[^\/]+\/.*\.(mp4|webm|ogg|mov|avi)/i)) {
console.log(`[VIDEO PROCESSOR] Detected Minio URL`);
return { type: 'minio', url };
}
// Local files (relative paths)
if (url.startsWith('/') && !url.startsWith('http')) {
console.log(`[VIDEO PROCESSOR] Detected local file path`);
return { type: 'local', url };
}
// Direct HTTP(S) URLs
console.log(`[VIDEO PROCESSOR] Detected direct HTTP URL`);
return { type: 'direct', url };
}
private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
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[]> {
console.log(`[VIDEO PROCESSOR] Processing ${type} video: ${url}`);
@ -270,18 +275,25 @@ export class VideoProcessor {
const parsed = this.normalizeMinioUrl(url);
if (parsed) {
try {
const cmd = new GetObjectCommand({ Bucket: parsed.bucket, Key: parsed.key });
const signed = await getSignedUrl(this.s3Client, cmd, { expiresIn: 3600 }); // 1h
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);
@ -581,16 +593,27 @@ export class VideoProcessor {
console.log(`[VIDEO PROCESSOR] Primary source: ${primarySource.type} (cached: ${primarySource.cached})`);
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 = [
controls ? 'controls' : '',
autoplay ? 'autoplay' : '',
muted ? 'muted' : '',
loop ? 'loop' : '',
needsCrossOrigin ? 'crossorigin="anonymous"' : '',
`preload="${preload}"`,
metadata.poster ? `poster="${metadata.poster}"` : '',
`data-video-title="${metadata.title || 'Embedded Video'}"`
].filter(Boolean).join(' ');
console.log(`[VIDEO PROCESSOR] Video attributes: ${videoAttributes}`);
const sourceTags = processedVideo.sources
@ -627,23 +650,39 @@ export class VideoProcessor {
return finalHTML;
}
private isSameOrigin(u: string): boolean {
try {
const site = process.env.PUBLIC_SITE_ORIGIN || '';
if (!site) return false;
return new URL(u).host === new URL(site).host;
} catch {
return false;
}
}
// Make getMimeType() robust against presigned URLs with query strings
private getMimeType(url: string): string {
const extension = url.split('.').pop()?.toLowerCase();
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'
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
m4v: 'video/m4v'
};
const mimeType = mimeTypes[extension || ''] || 'video/mp4';
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 {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);