diff --git a/backend/export.py b/backend/export.py
index 34cfd4a..04c8836 100644
--- a/backend/export.py
+++ b/backend/export.py
@@ -143,25 +143,22 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat
return find_media_in_attachments(image_name, note_folder, notes_dir)
-def generate_media_html(base64_url: str, media_type: str, alt_text: str) -> str:
- """Generate appropriate HTML for embedded media based on type."""
- safe_alt = alt_text.replace('"', '"')
+def generate_media_placeholder(media_type: str, alt_text: str) -> str:
+ """Generate a placeholder for non-embeddable media (audio, video, PDF)."""
+ safe_alt = alt_text.replace('"', '"').replace('<', '<').replace('>', '>')
- if media_type == 'audio':
- return f'''
-
-
{safe_alt}
+ icons = {'audio': '🎵', 'video': '🎬', 'document': '📄'}
+ labels = {'audio': 'Audio file', 'video': 'Video file', 'document': 'PDF document'}
+ icon = icons.get(media_type, '📎')
+ label = labels.get(media_type, 'Media file')
+
+ return f'''
+
{icon}
+
+
{safe_alt}
+
{label} — not available in exported view
+
'''
- elif media_type == 'video':
- return f'''
-
-
'''
- elif media_type == 'document':
- return f'''
-
-
'''
- else: # image
- return f''
def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
@@ -182,19 +179,23 @@ def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: P
media_name = match.group(1).strip()
alt_text = match.group(2).strip() if match.group(2) else media_name.split('/')[-1].rsplit('.', 1)[0]
- # Find the media file
+ # Check media type first
+ media_type = get_media_type(media_name)
+
+ # For non-image media (audio, video, PDF), show placeholder without embedding
+ if media_type in ('audio', 'video', 'document'):
+ return generate_media_placeholder(media_type, alt_text)
+
+ # For images, embed as base64
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if resolved_path:
- result = get_media_as_base64(resolved_path)
- if result:
- base64_url, media_type = result
- return generate_media_html(base64_url, media_type, alt_text)
+ base64_url = get_image_as_base64(resolved_path)
+ if base64_url:
+ return f''
- # Media not found, convert to placeholder
- media_type = get_media_type(media_name)
- icon = '🎵' if media_type == 'audio' else '🎬' if media_type == 'video' else '📄' if media_type == 'document' else '🖼️'
- return f'
{icon} {alt_text}'
+ # Image not found
+ return f'
🖼️ {alt_text}'
markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content)
@@ -213,6 +214,15 @@ def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: P
if not media_path:
return match.group(0)
+ # Check media type first
+ media_type = get_media_type(media_path)
+ display_alt = alt_text or Path(media_path).stem
+
+ # For non-image media (audio, video, PDF), show placeholder without embedding
+ if media_type in ('audio', 'video', 'document'):
+ return generate_media_placeholder(media_type, display_alt)
+
+ # For images, proceed with base64 embedding
# Handle /api/media/ or legacy /api/images/ paths (convert to filesystem paths)
if media_path.startswith('/api/media/'):
relative_path = media_path[len('/api/media/'):]
@@ -240,14 +250,12 @@ def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: P
# Path is outside notes_dir, skip
return match.group(0)
- # Get base64 data
- result = get_media_as_base64(resolved_path)
- if result:
- base64_url, media_type = result
- display_alt = alt_text or resolved_path.stem
- return generate_media_html(base64_url, media_type, display_alt)
+ # Get base64 data for image
+ base64_url = get_image_as_base64(resolved_path)
+ if base64_url:
+ return f''
- # Media not found, keep original
+ # Image not found, keep original
return match.group(0)
markdown_content = re.sub(img_pattern, replace_media, markdown_content)
diff --git a/frontend/app.js b/frontend/app.js
index ec63ceb..503e0db 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -2711,6 +2711,7 @@ function noteApp() {
this.currentNote = notePath;
this._lastRenderedContent = ''; // Clear render cache for new note
this._cachedRenderedHTML = '';
+ this._initializedVideoSources = new Set(); // Clear video cache for new note
this.noteContent = data.content;
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
this.currentMedia = ''; // Clear image viewer when loading a note
@@ -4188,6 +4189,17 @@ function noteApp() {
this.addCopyButtonToCodeBlock(pre);
}
});
+
+ // Enable video metadata loading (for first frame preview)
+ // Track by source URL to prevent duplicate requests on re-renders
+ if (!this._initializedVideoSources) this._initializedVideoSources = new Set();
+ previewEl.querySelectorAll('video[preload="none"]').forEach((video) => {
+ const src = video.getAttribute('src');
+ if (src && !this._initializedVideoSources.has(src)) {
+ this._initializedVideoSources.add(src);
+ video.preload = 'metadata';
+ }
+ });
}
}, 0);
@@ -4969,12 +4981,54 @@ function noteApp() {
// Get current rendered HTML (this already has markdown converted and will have LaTeX delimiters)
let renderedHTML = this.renderedMarkdown;
+ // Convert non-image media (audio, video, PDF) to placeholders first
+ // These shouldn't be embedded as base64 (too large)
+ // Use CSS variables with fallbacks for theme-aware styling (matches backend export.py)
+ const mediaPlaceholder = (type, name) => {
+ const icons = { audio: '🎵', video: '🎬', document: '📄' };
+ const labels = { audio: 'Audio file', video: 'Video file', document: 'PDF document' };
+ const icon = icons[type] || '📎';
+ const label = labels[type] || 'Media file';
+ return `
+
${icon}
+
+
${name}
+
${label} — not available in exported view
+
+
`;
+ };
+
+ // Replace audio embeds with placeholders
+ renderedHTML = renderedHTML.replace(
+ /