Compare commits

...

2 Commits

Author SHA1 Message Date
overcuriousity
75410e2b84 Merge branch 'main' of https://git.cc24.dev/mstoeck3/forensic-pathways 2025-08-13 14:04:20 +02:00
overcuriousity
88e79d7780 update video embed 2025-08-13 14:04:08 +02:00
5 changed files with 79 additions and 377 deletions

View File

@ -68,26 +68,6 @@ AI_EMBEDDINGS_MODEL=mistral-embed
# User rate limiting (queries per minute) # User rate limiting (queries per minute)
AI_RATE_LIMIT_MAX_REQUESTS=4 AI_RATE_LIMIT_MAX_REQUESTS=4
# ============================================================================
# 🎥 VIDEO EMBEDDING - PRODUCTION CONFIGURATION
# ============================================================================
# Enable local caching of Nextcloud videos (highly recommended)
VIDEO_CACHE_ENABLED=true
# Directory for cached videos (ensure it's writable and has sufficient space)
# This directory will grow over time as videos are cached permanently
VIDEO_CACHE_DIR=./cache/videos
# Emergency cleanup threshold in MB - videos are cached indefinitely
# Only triggers cleanup when approaching this limit to prevent disk full
# Recommended: 2000MB (2GB) for small deployments, 5000MB+ for larger ones
VIDEO_CACHE_MAX_SIZE=2000
# Maximum individual video file size for caching in MB
# Videos larger than this will stream directly without caching
VIDEO_MAX_SIZE=200
# ============================================================================ # ============================================================================
# CACHING BEHAVIOR # CACHING BEHAVIOR
# ============================================================================ # ============================================================================

View File

@ -1,46 +0,0 @@
---
// src/components/Video.astro - SIMPLE responsive video component
export interface Props {
src: string;
title?: string;
controls?: boolean;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
aspectRatio?: '16:9' | '4:3' | '1:1';
preload?: 'none' | 'metadata' | 'auto';
}
const {
src,
title = 'Video',
controls = true,
autoplay = false,
muted = false,
loop = false,
aspectRatio = '16:9',
preload = 'metadata'
} = Astro.props;
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
---
<div class={`video-container ${aspectClass}`}>
<video
src={src}
controls={controls}
autoplay={autoplay}
muted={muted}
loop={loop}
preload={preload}
style="width: 100%; height: 100%;"
data-video-title={title}
>
<p>Your browser does not support the video element.</p>
</video>
{title !== 'Video' && (
<div class="video-metadata">
<div class="video-title">{title}</div>
</div>
)}
</div>

View File

@ -691,12 +691,11 @@
/* ========================================================================== /* ==========================================================================
VIDEO EMBEDDING - Add to knowledgebase.css VIDEO EMBEDDING - ULTRA SIMPLE: Just full width, natural aspect ratios
========================================================================== */ ========================================================================== */
/* Video Container and Responsive Wrapper */ /* Video Container - just a styled wrapper */
:where(.markdown-content) .video-container { :where(.markdown-content) .video-container {
position: relative;
width: 100%; width: 100%;
margin: 2rem 0; margin: 2rem 0;
border-radius: var(--radius-lg, 0.75rem); border-radius: var(--radius-lg, 0.75rem);
@ -705,84 +704,34 @@
box-shadow: var(--shadow-lg, 0 12px 30px rgba(0,0,0,0.16)); box-shadow: var(--shadow-lg, 0 12px 30px rgba(0,0,0,0.16));
} }
/* Responsive 16:9 aspect ratio by default */ /* Video Element - full width, natural aspect ratio */
:where(.markdown-content) .video-container.aspect-16-9 {
aspect-ratio: 16 / 9;
}
:where(.markdown-content) .video-container.aspect-4-3 {
aspect-ratio: 4 / 3;
}
:where(.markdown-content) .video-container.aspect-1-1 {
aspect-ratio: 1 / 1;
}
/* Video Element Styling */
:where(.markdown-content) .video-container video { :where(.markdown-content) .video-container video {
width: 100%; width: 100%;
height: 100%; height: auto;
object-fit: contain; display: block;
background-color: #000; background-color: #000;
border: none; border: none;
outline: none; outline: none;
} }
/* Custom Video Controls Enhancement */ /* YouTube iframe - full width, preserve embedded dimensions ratio */
:where(.markdown-content) video::-webkit-media-controls-panel { :where(.markdown-content) .video-container iframe {
background-color: rgba(0, 0, 0, 0.8); width: 100%;
height: auto;
aspect-ratio: 16 / 9; /* Only for iframes since they don't have intrinsic ratio */
display: block;
border: none;
outline: none;
} }
:where(.markdown-content) video::-webkit-media-controls-current-time-display, /* Focus states for accessibility */
:where(.markdown-content) video::-webkit-media-controls-time-remaining-display { :where(.markdown-content) .video-container video:focus,
color: white; :where(.markdown-content) .video-container iframe:focus {
text-shadow: none; outline: 3px solid var(--color-primary);
outline-offset: 3px;
} }
/* Video Loading State */ /* Video Metadata */
:where(.markdown-content) .video-container .video-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--color-text-secondary);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
:where(.markdown-content) .video-container .video-loading .spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border);
border-top: 3px solid var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Video Error State */
:where(.markdown-content) .video-container .video-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: var(--color-error, #dc3545);
padding: 2rem;
}
:where(.markdown-content) .video-container .video-error .error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
/* Video Metadata Overlay */
:where(.markdown-content) .video-metadata { :where(.markdown-content) .video-metadata {
background-color: var(--color-bg-secondary); background-color: var(--color-bg-secondary);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -796,69 +745,13 @@
:where(.markdown-content) .video-metadata .video-title { :where(.markdown-content) .video-metadata .video-title {
font-weight: 600; font-weight: 600;
color: var(--color-text); color: var(--color-text);
margin-bottom: 0.5rem; margin: 0;
}
:where(.markdown-content) .video-metadata .video-info {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
:where(.markdown-content) .video-metadata .video-duration,
:where(.markdown-content) .video-metadata .video-size,
:where(.markdown-content) .video-metadata .video-format {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Fullscreen Support */
:where(.markdown-content) .video-container video:fullscreen {
background-color: #000;
}
:where(.markdown-content) .video-container video:-webkit-full-screen {
background-color: #000;
}
:where(.markdown-content) .video-container video:-moz-full-screen {
background-color: #000;
}
/* Video Thumbnail/Poster Styling */
:where(.markdown-content) .video-container video[poster] {
object-fit: cover;
}
/* Protected Video Overlay */
:where(.markdown-content) .video-container .video-protected {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
text-align: center;
padding: 2rem;
}
:where(.markdown-content) .video-container .video-protected .lock-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.8;
} }
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
:where(.markdown-content) .video-container { :where(.markdown-content) .video-container {
margin: 1.5rem -0.5rem; /* Extend to edges on mobile */ margin: 1.5rem -0.5rem;
border-radius: 0; border-radius: 0;
} }
@ -867,15 +760,9 @@
font-size: 0.8125rem; font-size: 0.8125rem;
border-radius: 0; border-radius: 0;
} }
:where(.markdown-content) .video-metadata .video-info {
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
}
} }
/* Dark Theme Adjustments */ /* Dark Theme */
[data-theme="dark"] :where(.markdown-content) .video-container { [data-theme="dark"] :where(.markdown-content) .video-container {
box-shadow: 0 12px 30px rgba(0,0,0,0.4); box-shadow: 0 12px 30px rgba(0,0,0,0.4);
} }
@ -885,48 +772,23 @@
border-color: color-mix(in srgb, var(--color-border) 60%, transparent); border-color: color-mix(in srgb, var(--color-border) 60%, transparent);
} }
/* Video Caption/Description Support */ /* Print Media */
:where(.markdown-content) .video-caption {
margin-top: 1rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
text-align: center;
font-style: italic;
line-height: 1.5;
}
/* Video Gallery Support (multiple videos) */
:where(.markdown-content) .video-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin: 2rem 0;
}
:where(.markdown-content) .video-gallery .video-container {
margin: 0;
}
/* Accessibility Improvements */
:where(.markdown-content) .video-container video:focus {
outline: 3px solid var(--color-primary);
outline-offset: 3px;
}
/* Print Media - Hide Videos */
@media print { @media print {
:where(.markdown-content) .video-container { :where(.markdown-content) .video-container {
border: 2px solid #ddd;
background-color: #f5f5f5;
padding: 2rem;
text-align: center;
}
:where(.markdown-content) .video-container video,
:where(.markdown-content) .video-container iframe {
display: none !important; display: none !important;
} }
:where(.markdown-content) .video-container::after { :where(.markdown-content) .video-container::before {
content: "[Video: " attr(data-video-title, "Embedded Video") "]"; content: "📹 Video: " attr(data-video-title, "Embedded Video");
display: block; display: block;
padding: 1rem; font-weight: 600;
background-color: #f5f5f5;
border: 1px solid #ddd;
text-align: center;
font-style: italic;
color: #666;
} }
} }

View File

@ -1,39 +1,49 @@
// src/utils/remarkVideoPlugin.ts // src/utils/remarkVideoPlugin.ts - SIMPLIFIED: Basic video and iframe enhancement only
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 } from 'hast'; import type { Root } from 'hast';
function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export const remarkVideoPlugin: Plugin<[], Root> = () => { export const remarkVideoPlugin: Plugin<[], Root> = () => {
return (tree: Root) => { return (tree: Root) => {
// Process video tags
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 srcMatch = node.value.match(/src=["']([^"']+)["']/); const srcMatch = node.value.match(/src=["']([^"']+)["']/);
const titleMatch = node.value.match(/title=["']([^"']+)["']/); const titleMatch = node.value.match(/title=["']([^"']+)["']/);
if (srcMatch) { if (srcMatch) {
const originalSrc = srcMatch[1]; const originalSrc = srcMatch[1];
const title = titleMatch?.[1] || 'Video'; const title = titleMatch?.[1] || 'Video';
// Extract existing attributes
const hasControls = node.value.includes('controls'); const hasControls = node.value.includes('controls');
const hasAutoplay = node.value.includes('autoplay'); const hasAutoplay = node.value.includes('autoplay');
const hasMuted = node.value.includes('muted'); const hasMuted = node.value.includes('muted');
const hasLoop = node.value.includes('loop'); const hasLoop = node.value.includes('loop');
const hasPreload = node.value.match(/preload=["']([^"']+)["']/); const preloadMatch = node.value.match(/preload=["']([^"']+)["']/);
// Create enhanced video with responsive wrapper
const enhancedHTML = ` const enhancedHTML = `
<div class="video-container aspect-16-9"> <div class="video-container">
<video <video
src="${escapeHtml(originalSrc)}" src="${escapeHtml(originalSrc)}"
${hasControls ? 'controls' : ''} ${hasControls ? 'controls' : ''}
${hasAutoplay ? 'autoplay' : ''} ${hasAutoplay ? 'autoplay' : ''}
${hasMuted ? 'muted' : ''} ${hasMuted ? 'muted' : ''}
${hasLoop ? 'loop' : ''} ${hasLoop ? 'loop' : ''}
${hasPreload ? `preload="${hasPreload[1]}"` : 'preload="metadata"'} ${preloadMatch ? `preload="${preloadMatch[1]}"` : 'preload="metadata"'}
style="width: 100%; height: 100%;"
data-video-title="${escapeHtml(title)}" data-video-title="${escapeHtml(title)}"
data-original-src="${escapeHtml(originalSrc)}"
> >
<p>Your browser does not support the video element.</p> <p>Your browser does not support the video element.</p>
</video> </video>
@ -46,23 +56,34 @@ export const remarkVideoPlugin: Plugin<[], Root> = () => {
`.trim(); `.trim();
parent.children[index] = { type: 'html', value: enhancedHTML }; parent.children[index] = { type: 'html', value: enhancedHTML };
console.log(`[VIDEO] Enhanced: ${title} (${originalSrc})`);
console.log(`[VIDEO] Processed: ${title}`);
console.log(`[VIDEO] Final URL: ${originalSrc}`);
} }
} }
// Process all iframes with consistent styling
if (node.value && node.value.includes('<iframe') && typeof index === 'number' && parent) {
// Skip if already wrapped in video-container to prevent double-wrapping
if (node.value.includes('video-container')) {
return;
}
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
const title = titleMatch?.[1] || 'Embedded Video';
// Wrap iframe in simple responsive container
const enhancedHTML = `
<div class="video-container">
${node.value}
</div>
<div class="video-metadata">
<div class="video-title">${escapeHtml(title)}</div>
</div>
`.trim();
parent.children[index] = { type: 'html', value: enhancedHTML };
console.log(`[VIDEO] Enhanced iframe: ${title}`);
}
}); });
}; };
}; };
function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@ -1,115 +0,0 @@
// src/utils/videoUtils.ts - SIMPLIFIED - Basic utilities only
import 'dotenv/config';
export interface SimpleVideoMetadata {
title?: string;
description?: string;
}
export function getVideoMimeType(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',
mkv: 'video/x-matroska',
flv: 'video/x-flv'
};
return (extension && mimeTypes[extension]) || 'video/mp4';
}
export function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function escapeHtml(unsafe: string): string {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export function generateVideoHTML(
src: string,
options: {
title?: string;
controls?: boolean;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
preload?: 'none' | 'metadata' | 'auto';
aspectRatio?: '16:9' | '4:3' | '1:1';
showMetadata?: boolean;
} = {}
): string {
const {
title = 'Video',
controls = true,
autoplay = false,
muted = false,
loop = false,
preload = 'metadata',
aspectRatio = '16:9',
showMetadata = true
} = options;
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
const videoAttributes = [
controls ? 'controls' : '',
autoplay ? 'autoplay' : '',
muted ? 'muted' : '',
loop ? 'loop' : '',
`preload="${preload}"`
].filter(Boolean).join(' ');
const metadataHTML = showMetadata && title !== 'Video' ? `
<div class="video-metadata">
<div class="video-title">${escapeHtml(title)}</div>
</div>
` : '';
return `
<div class="video-container ${aspectClass}">
<video
src="${escapeHtml(src)}"
${videoAttributes}
style="width: 100%; height: 100%;"
data-video-title="${escapeHtml(title)}"
>
<p>Your browser does not support the video element.</p>
</video>
${metadataHTML}
</div>
`.trim();
}