mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-05 23:18:53 -05:00
Compare commits
15 Commits
4e5b5a3e5b
...
567bb18e91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
567bb18e91 | ||
|
|
22e3d9d708 | ||
|
|
76341b11fa | ||
|
|
7343817393 | ||
|
|
ce0ae1a8da | ||
|
|
67ba1cd467 | ||
|
|
95cb6904ce | ||
|
|
0a68060700 | ||
|
|
6dc6b3e983 | ||
|
|
e0f13e9635 | ||
|
|
0fac57ee9b | ||
|
|
1de914ec02 | ||
|
|
653f7c0418 | ||
|
|
54e2f1d7e4 | ||
|
|
bfcb774183 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -31,3 +31,7 @@ static/video_editor/videos/sample-video-37s.mp4
|
|||||||
static/video_editor/videos/sample-video-10m.mp4
|
static/video_editor/videos/sample-video-10m.mp4
|
||||||
static/video_editor/videos/sample-video-10s.mp4
|
static/video_editor/videos/sample-video-10s.mp4
|
||||||
frontend-tools/video-js/public/videos/sample-video-white.mp4
|
frontend-tools/video-js/public/videos/sample-video-white.mp4
|
||||||
|
frontend-tools/video-editor/client/public/videos/sample-video.mp3
|
||||||
|
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
|
||||||
|
static/chapters_editor/videos/sample-video.mp3
|
||||||
|
static/video_editor/videos/sample-video.mp3
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
/templates/cms/*
|
/templates/cms/*
|
||||||
|
/templates/*.html
|
||||||
|
*.scss
|
||||||
@ -1 +1 @@
|
|||||||
VERSION = "6.7.1.beta-8"
|
VERSION = "6.7.1.beta-9"
|
||||||
|
|||||||
BIN
frontend-tools/chapters-editor/client/public/audio-poster.jpg
Normal file
BIN
frontend-tools/chapters-editor/client/public/audio-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
@ -0,0 +1,6 @@
|
|||||||
|
// Import the audio poster image as a module
|
||||||
|
// Vite will handle this and provide the correct URL
|
||||||
|
import audioPosterJpg from '../../public/audio-poster.jpg';
|
||||||
|
|
||||||
|
export const AUDIO_POSTER_URL = audioPosterJpg;
|
||||||
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { formatTime } from '@/lib/timeUtils';
|
import { formatTime } from '@/lib/timeUtils';
|
||||||
|
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
|
||||||
import '../styles/IOSVideoPlayer.css';
|
import '../styles/IOSVideoPlayer.css';
|
||||||
|
|
||||||
interface IOSVideoPlayerProps {
|
interface IOSVideoPlayerProps {
|
||||||
@ -11,6 +12,7 @@ interface IOSVideoPlayerProps {
|
|||||||
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
||||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||||
|
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// Refs for hold-to-continue functionality
|
// Refs for hold-to-continue functionality
|
||||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@ -26,15 +28,25 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
|
|
||||||
// Get the video source URL from the main player
|
// Get the video source URL from the main player
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let url = '';
|
||||||
if (videoRef.current && videoRef.current.querySelector('source')) {
|
if (videoRef.current && videoRef.current.querySelector('source')) {
|
||||||
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
|
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
|
||||||
if (source && source.src) {
|
if (source && source.src) {
|
||||||
setVideoUrl(source.src);
|
url = source.src;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to sample video if needed
|
// Fallback to sample video if needed
|
||||||
setVideoUrl('/videos/sample-video.mp4');
|
url = '/videos/sample-video.mp4';
|
||||||
}
|
}
|
||||||
|
setVideoUrl(url);
|
||||||
|
|
||||||
|
// Check if the media is an audio file and set poster image
|
||||||
|
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||||
|
|
||||||
|
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||||
|
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||||
|
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||||
|
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
// Function to jump 15 seconds backward
|
// Function to jump 15 seconds backward
|
||||||
@ -127,6 +139,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
x-webkit-airplay="allow"
|
x-webkit-airplay="allow"
|
||||||
preload="auto"
|
preload="auto"
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
|
poster={posterImage}
|
||||||
>
|
>
|
||||||
<source src={videoUrl} type="video/mp4" />
|
<source src={videoUrl} type="video/mp4" />
|
||||||
<p>Your browser doesn't support HTML5 video.</p>
|
<p>Your browser doesn't support HTML5 video.</p>
|
||||||
|
|||||||
@ -3947,11 +3947,11 @@ const TimelineControls = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowSaveChaptersModal(true)}
|
onClick={() => setShowSaveChaptersModal(true)}
|
||||||
className="save-chapters-button"
|
className="save-chapters-button"
|
||||||
data-tooltip={clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0
|
data-tooltip={clipSegments.length === 0
|
||||||
? "Clear all chapters"
|
? "Clear all chapters"
|
||||||
: "Save chapters"}
|
: "Save chapters"}
|
||||||
>
|
>
|
||||||
{clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0
|
{clipSegments.length === 0
|
||||||
? 'Clear Chapters'
|
? 'Clear Chapters'
|
||||||
: 'Save Chapters'}
|
: 'Save Chapters'}
|
||||||
</button>
|
</button>
|
||||||
@ -3974,7 +3974,7 @@ const TimelineControls = ({
|
|||||||
className="modal-button modal-button-primary"
|
className="modal-button modal-button-primary"
|
||||||
onClick={handleSaveChaptersConfirm}
|
onClick={handleSaveChaptersConfirm}
|
||||||
>
|
>
|
||||||
{clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length === 0
|
{clipSegments.length === 0
|
||||||
? 'Clear Chapters'
|
? 'Clear Chapters'
|
||||||
: 'Save Chapters'}
|
: 'Save Chapters'}
|
||||||
</button>
|
</button>
|
||||||
@ -3982,14 +3982,9 @@ const TimelineControls = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="modal-message">
|
<p className="modal-message">
|
||||||
{(() => {
|
{clipSegments.length === 0
|
||||||
const chaptersWithTitles = clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length;
|
? "Are you sure you want to clear all chapters? This will remove all existing chapters from the database."
|
||||||
if (chaptersWithTitles === 0) {
|
: `Are you sure you want to save the chapters? This will save ${clipSegments.filter((s) => s.chapterTitle && s.chapterTitle.trim()).length} chapters to the database.`}
|
||||||
return "Are you sure you want to clear all chapters? This will remove all existing chapters from the database.";
|
|
||||||
} else {
|
|
||||||
return `Are you sure you want to save the chapters? This will save ${chaptersWithTitles} chapters to the database.`;
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</p>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useState } from 'react';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
|
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
|
||||||
|
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
|
||||||
import logger from '../lib/logger';
|
import logger from '../lib/logger';
|
||||||
import '../styles/VideoPlayer.css';
|
import '../styles/VideoPlayer.css';
|
||||||
|
|
||||||
@ -38,6 +39,14 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
const sampleVideoUrl =
|
const sampleVideoUrl =
|
||||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
|
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
|
||||||
|
|
||||||
|
// Check if the media is an audio file
|
||||||
|
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||||
|
|
||||||
|
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||||
|
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||||
|
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||||
|
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
|
||||||
|
|
||||||
// Detect iOS device and Safari browser
|
// Detect iOS device and Safari browser
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkIOS = () => {
|
const checkIOS = () => {
|
||||||
@ -354,6 +363,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
x-webkit-airplay="allow"
|
x-webkit-airplay="allow"
|
||||||
controls={false}
|
controls={false}
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
|
poster={posterImage}
|
||||||
>
|
>
|
||||||
<source src={sampleVideoUrl} type="video/mp4" />
|
<source src={sampleVideoUrl} type="video/mp4" />
|
||||||
{/* Safari fallback for audio files */}
|
{/* Safari fallback for audio files */}
|
||||||
|
|||||||
@ -147,8 +147,15 @@ const useVideoChapters = () => {
|
|||||||
initialSegments.push(segment);
|
initialSegments.push(segment);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Start with empty state - no default segment
|
// Create a default segment that spans the entire video on first load
|
||||||
initialSegments = [];
|
const initialSegment: Segment = {
|
||||||
|
id: 1,
|
||||||
|
chapterTitle: '',
|
||||||
|
startTime: 0,
|
||||||
|
endTime: video.duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
initialSegments = [initialSegment];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize history state with the segments
|
// Initialize history state with the segments
|
||||||
@ -267,17 +274,24 @@ const useVideoChapters = () => {
|
|||||||
|
|
||||||
// Check if we now have duration and initialize if needed
|
// Check if we now have duration and initialize if needed
|
||||||
if (video.duration > 0 && clipSegments.length === 0) {
|
if (video.duration > 0 && clipSegments.length === 0) {
|
||||||
logger.debug('Safari: Successfully initialized metadata with empty state');
|
logger.debug('Safari: Successfully initialized metadata, creating default segment');
|
||||||
|
|
||||||
|
const defaultSegment: Segment = {
|
||||||
|
id: 1,
|
||||||
|
chapterTitle: '',
|
||||||
|
startTime: 0,
|
||||||
|
endTime: video.duration,
|
||||||
|
};
|
||||||
|
|
||||||
setDuration(video.duration);
|
setDuration(video.duration);
|
||||||
setTrimEnd(video.duration);
|
setTrimEnd(video.duration);
|
||||||
setClipSegments([]);
|
setClipSegments([defaultSegment]);
|
||||||
|
|
||||||
const initialState: EditorState = {
|
const initialState: EditorState = {
|
||||||
trimStart: 0,
|
trimStart: 0,
|
||||||
trimEnd: video.duration,
|
trimEnd: video.duration,
|
||||||
splitPoints: [],
|
splitPoints: [],
|
||||||
clipSegments: [],
|
clipSegments: [defaultSegment],
|
||||||
};
|
};
|
||||||
|
|
||||||
setHistory([initialState]);
|
setHistory([initialState]);
|
||||||
|
|||||||
@ -6,6 +6,7 @@ if (typeof window !== 'undefined') {
|
|||||||
window.MEDIA_DATA = {
|
window.MEDIA_DATA = {
|
||||||
videoUrl: '',
|
videoUrl: '',
|
||||||
mediaId: '',
|
mediaId: '',
|
||||||
|
posterUrl: ''
|
||||||
};
|
};
|
||||||
window.lastSeekedPosition = 0;
|
window.lastSeekedPosition = 0;
|
||||||
}
|
}
|
||||||
@ -15,6 +16,7 @@ declare global {
|
|||||||
MEDIA_DATA: {
|
MEDIA_DATA: {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
|
posterUrl?: string;
|
||||||
};
|
};
|
||||||
seekToFunction?: (time: number) => void;
|
seekToFunction?: (time: number) => void;
|
||||||
lastSeekedPosition: number;
|
lastSeekedPosition: number;
|
||||||
|
|||||||
32
frontend-tools/chapters-editor/client/src/vite-env.d.ts
vendored
Normal file
32
frontend-tools/chapters-editor/client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.jpg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpeg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.gif' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.webp' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
@ -32,8 +32,14 @@ export default defineConfig({
|
|||||||
output: {
|
output: {
|
||||||
assetFileNames: (assetInfo) => {
|
assetFileNames: (assetInfo) => {
|
||||||
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
|
if (assetInfo.name === 'style.css') return 'chapters-editor.css';
|
||||||
return assetInfo.name;
|
// Keep original names for image assets
|
||||||
|
if (assetInfo.name && /\.(png|jpe?g|svg|gif|webp)$/i.test(assetInfo.name)) {
|
||||||
|
return assetInfo.name;
|
||||||
|
}
|
||||||
|
return assetInfo.name || 'asset-[hash][extname]';
|
||||||
},
|
},
|
||||||
|
// Inline small assets, emit larger ones
|
||||||
|
inlineDynamicImports: true,
|
||||||
globals: {
|
globals: {
|
||||||
react: 'React',
|
react: 'React',
|
||||||
'react-dom': 'ReactDOM',
|
'react-dom': 'ReactDOM',
|
||||||
@ -44,5 +50,7 @@ export default defineConfig({
|
|||||||
outDir: '../../../static/video_editor',
|
outDir: '../../../static/video_editor',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
external: ['react', 'react-dom'],
|
external: ['react', 'react-dom'],
|
||||||
|
// Inline assets smaller than 100KB, emit larger ones
|
||||||
|
assetsInlineLimit: 102400,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
frontend-tools/video-editor/client/public/audio-poster.jpg
Normal file
BIN
frontend-tools/video-editor/client/public/audio-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
@ -236,6 +236,46 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
// Don't handle keyboard shortcuts if user is typing in an input field
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.code) {
|
||||||
|
case 'Space':
|
||||||
|
event.preventDefault(); // Prevent default spacebar behavior (scrolling, button activation)
|
||||||
|
handlePlay();
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
event.preventDefault();
|
||||||
|
if (videoRef.current) {
|
||||||
|
const newTime = Math.max(currentTime - 10, 0);
|
||||||
|
handleMobileSafeSeek(newTime);
|
||||||
|
logger.debug('Jumped backward 10 seconds to:', formatDetailedTime(newTime));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
event.preventDefault();
|
||||||
|
if (videoRef.current) {
|
||||||
|
const newTime = Math.min(currentTime + 10, duration);
|
||||||
|
handleMobileSafeSeek(newTime);
|
||||||
|
logger.debug('Jumped forward 10 seconds to:', formatDetailedTime(newTime));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handlePlay, handleMobileSafeSeek, currentTime, duration, videoRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-screen">
|
<div className="bg-background min-h-screen">
|
||||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
// Import the audio poster image as a module
|
||||||
|
// Vite will handle this and provide the correct URL
|
||||||
|
import audioPosterJpg from '../../public/audio-poster.jpg';
|
||||||
|
|
||||||
|
export const AUDIO_POSTER_URL = audioPosterJpg;
|
||||||
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { formatTime } from '@/lib/timeUtils';
|
import { formatTime } from '@/lib/timeUtils';
|
||||||
|
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
|
||||||
import '../styles/IOSVideoPlayer.css';
|
import '../styles/IOSVideoPlayer.css';
|
||||||
|
|
||||||
interface IOSVideoPlayerProps {
|
interface IOSVideoPlayerProps {
|
||||||
@ -11,6 +12,7 @@ interface IOSVideoPlayerProps {
|
|||||||
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
||||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||||
|
const [posterImage, setPosterImage] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
// Refs for hold-to-continue functionality
|
// Refs for hold-to-continue functionality
|
||||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@ -26,15 +28,25 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
|
|
||||||
// Get the video source URL from the main player
|
// Get the video source URL from the main player
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let url = '';
|
||||||
if (videoRef.current && videoRef.current.querySelector('source')) {
|
if (videoRef.current && videoRef.current.querySelector('source')) {
|
||||||
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
|
const source = videoRef.current.querySelector('source') as HTMLSourceElement;
|
||||||
if (source && source.src) {
|
if (source && source.src) {
|
||||||
setVideoUrl(source.src);
|
url = source.src;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to sample video if needed
|
// Fallback to sample video if needed
|
||||||
setVideoUrl('/videos/sample-video.mp4');
|
url = '/videos/sample-video.mp3';
|
||||||
}
|
}
|
||||||
|
setVideoUrl(url);
|
||||||
|
|
||||||
|
// Check if the media is an audio file and set poster image
|
||||||
|
const isAudioFile = url.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||||
|
|
||||||
|
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||||
|
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||||
|
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||||
|
setPosterImage(isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined));
|
||||||
}, [videoRef]);
|
}, [videoRef]);
|
||||||
|
|
||||||
// Function to jump 15 seconds backward
|
// Function to jump 15 seconds backward
|
||||||
@ -127,6 +139,7 @@ const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps
|
|||||||
x-webkit-airplay="allow"
|
x-webkit-airplay="allow"
|
||||||
preload="auto"
|
preload="auto"
|
||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
|
poster={posterImage}
|
||||||
>
|
>
|
||||||
<source src={videoUrl} type="video/mp4" />
|
<source src={videoUrl} type="video/mp4" />
|
||||||
<p>Your browser doesn't support HTML5 video.</p>
|
<p>Your browser doesn't support HTML5 video.</p>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { formatTime, formatDetailedTime } from '../lib/timeUtils';
|
|||||||
import { generateThumbnail, generateSolidColor } from '../lib/videoUtils';
|
import { generateThumbnail, generateSolidColor } from '../lib/videoUtils';
|
||||||
import { Segment } from './ClipSegments';
|
import { Segment } from './ClipSegments';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import { trimVideo, autoSaveVideo, fetchAutoSavedSegments } from '../services/videoApi';
|
import { trimVideo, autoSaveVideo } from '../services/videoApi';
|
||||||
import logger from '../lib/logger';
|
import logger from '../lib/logger';
|
||||||
import '../styles/TimelineControls.css';
|
import '../styles/TimelineControls.css';
|
||||||
import '../styles/TwoRowTooltip.css';
|
import '../styles/TwoRowTooltip.css';
|
||||||
@ -1078,44 +1078,10 @@ const TimelineControls = ({
|
|||||||
// Get savedSegments directly from window.MEDIA_DATA
|
// Get savedSegments directly from window.MEDIA_DATA
|
||||||
let savedData = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.savedSegments) || null;
|
let savedData = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.savedSegments) || null;
|
||||||
|
|
||||||
// If no saved segments, use default segments
|
// If no saved segments, don't load anything - the useVideoTrimmer hook already creates the initial full-length segment
|
||||||
if (!savedData) {
|
if (!savedData) {
|
||||||
logger.debug('No saved segments found in MEDIA_DATA, using default segments');
|
logger.debug('No saved segments found in MEDIA_DATA, skipping load (initial segment already created by useVideoTrimmer)');
|
||||||
savedData = {
|
return;
|
||||||
segments: [
|
|
||||||
{
|
|
||||||
startTime: '00:00:01.130',
|
|
||||||
endTime: '00:00:05.442',
|
|
||||||
name: 'segment',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
startTime: '00:00:06.152',
|
|
||||||
endTime: '00:00:10.518',
|
|
||||||
name: 'segment',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
startTime: '00:00:11.518',
|
|
||||||
endTime: '00:00:15.121',
|
|
||||||
name: 'segment',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
startTime: '00:00:16.757',
|
|
||||||
endTime: '00:00:20.769',
|
|
||||||
name: 'segment',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
startTime: '00:00:21.158',
|
|
||||||
endTime: '00:00:25.870',
|
|
||||||
name: 'segment',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
startTime: '00:00:26.430',
|
|
||||||
endTime: '00:00:29.798',
|
|
||||||
name: 'segment',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
updated_at: '2025-06-24 14:59:14',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('Loading saved segments:', savedData);
|
logger.debug('Loading saved segments:', savedData);
|
||||||
@ -1132,7 +1098,6 @@ const TimelineControls = ({
|
|||||||
endTime: parseTimeString(seg.endTime),
|
endTime: parseTimeString(seg.endTime),
|
||||||
thumbnail: '',
|
thumbnail: '',
|
||||||
}));
|
}));
|
||||||
console.log('convertedSegments', convertedSegments);
|
|
||||||
|
|
||||||
// Dispatch event to update segments
|
// Dispatch event to update segments
|
||||||
const updateEvent = new CustomEvent('update-segments', {
|
const updateEvent = new CustomEvent('update-segments', {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useState } from 'react';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
|
import { formatTime, formatDetailedTime } from '@/lib/timeUtils';
|
||||||
|
import { AUDIO_POSTER_URL } from '@/assets/audioPosterUrl';
|
||||||
import logger from '../lib/logger';
|
import logger from '../lib/logger';
|
||||||
import '../styles/VideoPlayer.css';
|
import '../styles/VideoPlayer.css';
|
||||||
|
|
||||||
@ -36,7 +37,15 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
const [tooltipTime, setTooltipTime] = useState(0);
|
const [tooltipTime, setTooltipTime] = useState(0);
|
||||||
|
|
||||||
const sampleVideoUrl =
|
const sampleVideoUrl =
|
||||||
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp4';
|
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.videoUrl) || '/videos/sample-video.mp3';
|
||||||
|
|
||||||
|
// Check if the media is an audio file
|
||||||
|
const isAudioFile = sampleVideoUrl.match(/\.(mp3|wav|ogg|m4a|aac|flac)$/i) !== null;
|
||||||
|
|
||||||
|
// Get posterUrl from MEDIA_DATA, or use audio-poster.jpg as fallback for audio files when posterUrl is empty, null, or "None"
|
||||||
|
const mediaPosterUrl = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.posterUrl) || '';
|
||||||
|
const isValidPoster = mediaPosterUrl && mediaPosterUrl !== 'None' && mediaPosterUrl.trim() !== '';
|
||||||
|
const posterImage = isValidPoster ? mediaPosterUrl : (isAudioFile ? AUDIO_POSTER_URL : undefined);
|
||||||
|
|
||||||
// Detect iOS device
|
// Detect iOS device
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -344,6 +353,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|||||||
x-webkit-airplay="allow"
|
x-webkit-airplay="allow"
|
||||||
controls={false}
|
controls={false}
|
||||||
muted={isMuted}
|
muted={isMuted}
|
||||||
|
poster={posterImage}
|
||||||
>
|
>
|
||||||
<source src={sampleVideoUrl} type="video/mp4" />
|
<source src={sampleVideoUrl} type="video/mp4" />
|
||||||
<p>Your browser doesn't support HTML5 video.</p>
|
<p>Your browser doesn't support HTML5 video.</p>
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import "./index.css";
|
|||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.MEDIA_DATA = {
|
window.MEDIA_DATA = {
|
||||||
videoUrl: "",
|
videoUrl: "",
|
||||||
mediaId: ""
|
mediaId: "",
|
||||||
|
posterUrl: ""
|
||||||
};
|
};
|
||||||
window.lastSeekedPosition = 0;
|
window.lastSeekedPosition = 0;
|
||||||
}
|
}
|
||||||
@ -15,6 +16,7 @@ declare global {
|
|||||||
MEDIA_DATA: {
|
MEDIA_DATA: {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
|
posterUrl?: string;
|
||||||
};
|
};
|
||||||
seekToFunction?: (time: number) => void;
|
seekToFunction?: (time: number) => void;
|
||||||
lastSeekedPosition: number;
|
lastSeekedPosition: number;
|
||||||
|
|||||||
32
frontend-tools/video-editor/client/src/vite-env.d.ts
vendored
Normal file
32
frontend-tools/video-editor/client/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.jpg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpeg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.gif' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.webp' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|
||||||
@ -32,11 +32,17 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
// Ensure CSS file has a predictable name
|
// Ensure CSS file has a predictable name and keep image assets
|
||||||
assetFileNames: (assetInfo) => {
|
assetFileNames: (assetInfo) => {
|
||||||
if (assetInfo.name === 'style.css') return 'video-editor.css';
|
if (assetInfo.name === 'style.css') return 'video-editor.css';
|
||||||
return assetInfo.name;
|
// Keep original names for image assets
|
||||||
|
if (assetInfo.name && /\.(png|jpe?g|svg|gif|webp)$/i.test(assetInfo.name)) {
|
||||||
|
return assetInfo.name;
|
||||||
|
}
|
||||||
|
return assetInfo.name || 'asset-[hash][extname]';
|
||||||
},
|
},
|
||||||
|
// Inline small assets, emit larger ones
|
||||||
|
inlineDynamicImports: true,
|
||||||
// Add this to ensure the final bundle exposes React correctly
|
// Add this to ensure the final bundle exposes React correctly
|
||||||
globals: {
|
globals: {
|
||||||
'react': 'React',
|
'react': 'React',
|
||||||
@ -47,6 +53,8 @@ export default defineConfig({
|
|||||||
// Output to Django's static directory
|
// Output to Django's static directory
|
||||||
outDir: '../../../static/video_editor',
|
outDir: '../../../static/video_editor',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
external: ['react', 'react-dom']
|
external: ['react', 'react-dom'],
|
||||||
|
// Inline assets smaller than 100KB, emit larger ones
|
||||||
|
assetsInlineLimit: 102400,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
BIN
static/chapters_editor/audio-poster.jpg
Normal file
BIN
static/chapters_editor/audio-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
static/video_editor/audio-poster.jpg
Normal file
BIN
static/video_editor/audio-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -13,13 +13,12 @@
|
|||||||
<script>
|
<script>
|
||||||
window.MEDIA_DATA = {
|
window.MEDIA_DATA = {
|
||||||
videoUrl: "{{ media_file_path }}",
|
videoUrl: "{{ media_file_path }}",
|
||||||
|
posterUrl: "{{ media_object.poster_url }}",
|
||||||
mediaId: "{{ media_object.friendly_token }}",
|
mediaId: "{{ media_object.friendly_token }}",
|
||||||
redirectURL: "{{ media_object.get_absolute_url }}",
|
redirectURL: "{{ media_object.get_absolute_url }}",
|
||||||
redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}",
|
redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}",
|
||||||
chapters: {{ chapters|safe }},
|
chapters: {{ chapters|safe }},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(window.MEDIA_DATA.chapters)
|
|
||||||
</script>
|
</script>
|
||||||
{%endblock topimports %}
|
{%endblock topimports %}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
<script>
|
<script>
|
||||||
window.MEDIA_DATA = {
|
window.MEDIA_DATA = {
|
||||||
videoUrl: "{{ media_file_path }}",
|
videoUrl: "{{ media_file_path }}",
|
||||||
|
posterUrl: "{{ media_object.poster_url }}",
|
||||||
mediaId: "{{ media_object.friendly_token }}",
|
mediaId: "{{ media_object.friendly_token }}",
|
||||||
redirectURL: "{{ media_object.get_absolute_url }}",
|
redirectURL: "{{ media_object.get_absolute_url }}",
|
||||||
redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}"
|
redirectUserMediaURL: "{{ media_object.user.get_absolute_url }}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user