videos #17
@ -18,6 +18,9 @@ sections:
|
||||
review_status: "published"
|
||||
---
|
||||
|
||||

|
||||
<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!
|
||||
|
||||
|
||||
|
@ -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
|
||||
// 
|
||||
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
|
||||
};
|
||||
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
|
||||
};
|
||||
|
||||
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;
|
||||
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: {
|
||||
function buildProcessedVideoHTML(src: string, metadata: any, options: {
|
||||
controls: boolean;
|
||||
autoplay: boolean;
|
||||
muted: boolean;
|
||||
|
@ -147,37 +147,42 @@ 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);
|
||||
@ -580,16 +592,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}`);
|
||||
|
||||
@ -626,24 +649,40 @@ export class VideoProcessor {
|
||||
|
||||
return finalHTML;
|
||||
}
|
||||
|
||||
private getMimeType(url: string): string {
|
||||
const extension = url.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 = mimeTypes[extension || ''] || 'video/mp4';
|
||||
console.log(`[VIDEO PROCESSOR] MIME type for extension '${extension}': ${mimeType}`);
|
||||
|
||||
return mimeType;
|
||||
|
||||
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 {
|
||||
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 {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
Loading…
x
Reference in New Issue
Block a user