feat: Video Trimmer and more

This commit is contained in:
Yiannis Christodoulou
2025-05-15 10:43:26 +03:00
committed by GitHub
parent ab4d9d67df
commit a833b606f1
102 changed files with 13266 additions and 523 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
server/public
vite.config.ts.*
*.tar.gz
yt.readme.md
@@ -0,0 +1 @@
*
+132
View File
@@ -0,0 +1,132 @@
# MediaCMS Video Editor
A modern browser-based video editing tool built with React and TypeScript that integrates with MediaCMS. The editor allows users to trim, split, and manage video segments with a timeline interface.
## Features
- ⏱️ Trim video start and end points
- ✂️ Split videos into multiple segments
- 👁️ Preview individual segments or the full edited video
- 🔍 Zoom timeline for precise editing
- 🔄 Undo/redo support for all editing operations
- 🔊 Audio mute controls
- 💾 Save edits directly to MediaCMS
## Tech Stack
- React 18
- TypeScript
- Vite
- Tailwind CSS
- Express (for development server)
- Drizzle ORM
## Installation
### Prerequisites
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
- Yarn or npm package manager
### Setup
```bash
# Navigate to the video editor directory
cd frontend-tools/video-editor
# Install dependencies with Yarn
yarn install
# Or with npm
npm install
```
## Development
The video editor can be run in two modes:
### Standalone Development Mode
This starts a local development server with hot reloading:
```bash
# Start the development server with Yarn
yarn dev
# Or with npm
npm run dev
```
### Frontend-only Development Mode
If you want to work only on the frontend with MediaCMS backend:
```bash
# Start frontend-only development with Yarn
yarn dev:frontend
# Or with npm
npm run dev:frontend
```
## Building
### For MediaCMS Integration
To build the video editor for integration with MediaCMS:
```bash
# Build for Django integration with Yarn
yarn build:django
# Or with npm
npm run build:django
```
This will compile the editor and place the output in the MediaCMS static directory.
### Standalone Build
To build the editor as a standalone application:
```bash
# Build for production with Yarn
yarn build
# Or with npm
npm run build
```
## Deployment
To deploy the video editor, you can use the build and deploy script (recommended):
```bash
# Run the deployment script
sh deploy/scripts/build_and_deploy.sh
```
The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
You can also deploy manually after building:
```bash
# With Yarn
yarn deploy
# Or with npm
npm run deploy
```
## Project Structure
- `/src` - Source code
- `/components` - React components
- `/hooks` - Custom React hooks
- `/lib` - Utility functions and helpers
- `/services` - API services
- `/styles` - CSS and style definitions
## API Integration
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1"
/>
</head>
<body>
<div id="video-editor-trim-root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,100 @@
import VideoPlayer from "@/components/VideoPlayer";
import TimelineControls from "@/components/TimelineControls";
import EditingTools from "@/components/EditingTools";
import ClipSegments from "@/components/ClipSegments";
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
const App = () => {
const {
videoRef,
currentTime,
duration,
isPlaying,
isPreviewMode,
isMuted,
thumbnails,
trimStart,
trimEnd,
splitPoints,
zoomLevel,
clipSegments,
historyPosition,
history,
hasUnsavedChanges,
playPauseVideo,
seekVideo,
handleTrimStartChange,
handleTrimEndChange,
handleSplit,
handleReset,
handleUndo,
handleRedo,
handlePreview,
handlePlay,
handleZoomChange,
toggleMute,
handleSave,
handleSaveACopy,
handleSaveSegments,
} = useVideoTrimmer();
return (
<div className="bg-background min-h-screen">
<div className="container mx-auto px-4 py-6 max-w-6xl">
{/* Video Player */}
<VideoPlayer
videoRef={videoRef}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
isMuted={isMuted}
onPlayPause={playPauseVideo}
onSeek={seekVideo}
onToggleMute={toggleMute}
/>
{/* Editing Tools */}
<EditingTools
onSplit={handleSplit}
onReset={handleReset}
onUndo={handleUndo}
onRedo={handleRedo}
onPreview={handlePreview}
onPlay={handlePlay}
isPreviewMode={isPreviewMode}
isPlaying={isPlaying}
canUndo={historyPosition > 0}
canRedo={historyPosition < history.length - 1}
/>
{/* Timeline Controls */}
<TimelineControls
currentTime={currentTime}
duration={duration}
thumbnails={thumbnails}
trimStart={trimStart}
trimEnd={trimEnd}
splitPoints={splitPoints}
zoomLevel={zoomLevel}
clipSegments={clipSegments}
onTrimStartChange={handleTrimStartChange}
onTrimEndChange={handleTrimEndChange}
onZoomChange={handleZoomChange}
onSeek={seekVideo}
videoRef={videoRef}
onSave={handleSave}
onSaveACopy={handleSaveACopy}
onSaveSegments={handleSaveSegments}
isPreviewMode={isPreviewMode}
hasUnsavedChanges={hasUnsavedChanges}
/>
{/* Clip Segments */}
<ClipSegments segments={clipSegments} />
</div>
</div>
);
};
export default App;
@@ -0,0 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 832 B

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 813 B

@@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 597 B

@@ -0,0 +1,4 @@
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
.st0{fill:#333333;}
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 611 B

@@ -0,0 +1,9 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<g data-name="1" id="_1">
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
<g transform="translate(6, 0)">
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 397 B

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

@@ -0,0 +1,6 @@
<?xml version="1.0" ?>
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
<g data-name="1" id="_1">
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 364 B

@@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>

After

Width:  |  Height:  |  Size: 359 B

@@ -0,0 +1,86 @@
import { formatTime, formatLongTime } from "@/lib/timeUtils";
import '../styles/ClipSegments.css';
export interface Segment {
id: number;
name: string;
startTime: number;
endTime: number;
thumbnail: string;
}
interface ClipSegmentsProps {
segments: Segment[];
}
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Sort segments by startTime
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Handle delete segment click
const handleDeleteSegment = (segmentId: number) => {
// Create and dispatch the delete event
const deleteEvent = new CustomEvent('delete-segment', {
detail: { segmentId }
});
document.dispatchEvent(deleteEvent);
};
// Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 8
// This matches the CSS nth-child selectors in the timeline
return `segment-default-color segment-color-${(index % 8) + 1}`;
};
return (
<div className="clip-segments-container">
<h3 className="clip-segments-title">Clip Segments</h3>
{sortedSegments.map((segment, index) => (
<div
key={segment.id}
className={`segment-item ${getSegmentColorClass(index)}`}
>
<div className="segment-content">
<div
className="segment-thumbnail"
style={{ backgroundImage: `url(${segment.thumbnail})` }}
></div>
<div className="segment-info">
<div className="segment-title">
Segment {index + 1}
</div>
<div className="segment-time">
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
</div>
<div className="segment-duration">
Duration: {formatLongTime(segment.endTime - segment.startTime)}
</div>
</div>
</div>
<div className="segment-actions">
<button
className="delete-button"
aria-label="Delete Segment"
data-tooltip="Delete this segment"
onClick={() => handleDeleteSegment(segment.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
))}
{sortedSegments.length === 0 && (
<div className="empty-message">
No segments created yet. Use the split button to create segments.
</div>
)}
</div>
);
};
export default ClipSegments;
@@ -0,0 +1,151 @@
import '../styles/EditingTools.css';
interface EditingToolsProps {
onSplit: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onPreview: () => void;
onPlay: () => void;
canUndo: boolean;
canRedo: boolean;
isPreviewMode?: boolean;
isPlaying?: boolean;
}
const EditingTools = ({
onSplit,
onReset,
onUndo,
onRedo,
onPreview,
onPlay,
canUndo,
canRedo,
isPreviewMode = false,
isPlaying = false,
}: EditingToolsProps) => {
return (
<div className="editing-tools-container">
<div className="flex-container single-row">
{/* Left side - Play buttons group */}
<div className="button-group play-buttons-group">
{/* Play Preview button */}
<button
className="button preview-button"
onClick={onPreview}
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
style={{ fontSize: '0.875rem' }}
>
{isPreviewMode ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Stop Preview</span>
<span className="short-text">Stop</span>
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play Preview</span>
<span className="short-text">Preview</span>
</>
)}
</button>
{/* Standard Play button (only shown when not in preview mode) */}
{!isPreviewMode && (
<button
className="button play-button"
onClick={onPlay}
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
style={{ fontSize: '0.875rem' }}
>
{isPlaying ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="10" y1="15" x2="10" y2="9" />
<line x1="14" y1="15" x2="14" y2="9" />
</svg>
<span className="full-text">Pause</span>
<span className="short-text">Pause</span>
</>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</svg>
<span className="full-text">Play</span>
<span className="short-text">Play</span>
</>
)}
</button>
)}
{/* Preview mode message (replaces play button) */}
{isPreviewMode && (
<div className="preview-mode-message">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="8" />
</svg>
Preview Mode
</div>
)}
</div>
{/* Right side - Editing tools */}
<div className="button-group secondary">
<button
className="button"
aria-label="Undo"
data-tooltip="Undo last action"
disabled={!canUndo}
onClick={onUndo}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 14 4 9l5-5"/>
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>
</svg>
<span className="button-text">Undo</span>
</button>
<button
className="button"
aria-label="Redo"
data-tooltip="Redo last undone action"
disabled={!canRedo}
onClick={onRedo}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 14 5-5-5-5"/>
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>
</svg>
<span className="button-text">Redo</span>
</button>
<div className="divider"></div>
<button
className="button"
onClick={onReset}
data-tooltip="Reset to full video"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
<span className="reset-text">Reset</span>
</button>
</div>
</div>
</div>
);
};
export default EditingTools;
@@ -0,0 +1,90 @@
import React, { useEffect } from 'react';
import '../styles/Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
actions?: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
actions
}) => {
// Close modal when Escape key is pressed
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscapeKey);
// Disable body scrolling when modal is open
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscapeKey);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
// Handle click outside the modal content to close it
const handleClickOutside = (event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleClickOutside}>
<div className="modal-container" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{title}</h2>
<button
className="modal-close-button"
onClick={onClose}
aria-label="Close modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div className="modal-content">
{children}
</div>
{actions && (
<div className="modal-actions">
{actions}
</div>
)}
</div>
</div>
);
};
export default Modal;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
import { useRef, useEffect } from "react";
import { formatTime } from "@/lib/timeUtils";
import '../styles/VideoPlayer.css';
interface VideoPlayerProps {
videoRef: React.RefObject<HTMLVideoElement>;
currentTime: number;
duration: number;
isPlaying: boolean;
isMuted?: boolean;
onPlayPause: () => void;
onSeek: (time: number) => void;
onToggleMute?: () => void;
}
const VideoPlayer = ({
videoRef,
currentTime,
duration,
isPlaying,
isMuted = false,
onPlayPause,
onSeek,
onToggleMute
}: VideoPlayerProps) => {
const progressRef = useRef<HTMLDivElement>(null);
const sampleVideoUrl = typeof window !== 'undefined' &&
(window as any).MEDIA_DATA?.videoUrl ||
"https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
// Add iOS-specific attributes to prevent fullscreen playback
useEffect(() => {
if (videoRef.current) {
// These attributes need to be set directly on the DOM element
// for iOS Safari to respect inline playback
videoRef.current.setAttribute('playsinline', 'true');
videoRef.current.setAttribute('webkit-playsinline', 'true');
videoRef.current.setAttribute('x-webkit-airplay', 'allow');
}
}, [videoRef]);
// Jump 10 seconds forward
const handleForward = () => {
onSeek(Math.min(currentTime + 10, duration));
};
// Jump 10 seconds backward
const handleBackward = () => {
onSeek(Math.max(currentTime - 10, 0));
};
// Calculate progress percentage
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
// Handle click on progress bar
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (progressRef.current) {
const rect = progressRef.current.getBoundingClientRect();
const clickPosition = (e.clientX - rect.left) / rect.width;
onSeek(duration * clickPosition);
}
};
// Handle toggling fullscreen
const handleFullscreen = () => {
if (videoRef.current) {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.current.requestFullscreen();
}
}
};
return (
<div className="video-player-container">
<video
ref={videoRef}
preload="auto"
crossOrigin="anonymous"
onClick={onPlayPause}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
controls={false}
muted={isMuted}
>
<source src={sampleVideoUrl} type="video/mp4" />
<p>Your browser doesn't support HTML5 video.</p>
</video>
{/* Play/Pause Indicator (shows based on current state) */}
<div className={`play-pause-indicator ${isPlaying ? 'pause-icon' : 'play-icon'}`}></div>
{/* Video Controls Overlay */}
<div className="video-controls">
{/* Time and Duration */}
<div className="video-time-display">
<span className="video-current-time">{formatTime(currentTime)}</span>
<span className="video-duration">/ {formatTime(duration)}</span>
</div>
{/* Progress Bar */}
<div
ref={progressRef}
className="video-progress"
onClick={handleProgressClick}
>
<div
className="video-progress-fill"
style={{ width: `${progressPercentage}%` }}
></div>
<div
className="video-scrubber"
style={{ left: `${progressPercentage}%` }}
></div>
</div>
{/* Controls - Mute and Fullscreen buttons */}
<div className="video-controls-buttons">
{/* Mute/Unmute Button */}
{onToggleMute && (
<button
className="mute-button"
aria-label={isMuted ? "Unmute" : "Mute"}
onClick={onToggleMute}
data-tooltip={isMuted ? "Unmute" : "Mute"}
>
{isMuted ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
)}
</button>
)}
{/* Fullscreen Button */}
<button
className="fullscreen-button"
aria-label="Fullscreen"
onClick={handleFullscreen}
data-tooltip="Toggle fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
);
};
export default VideoPlayer;
@@ -0,0 +1,931 @@
import { useState, useRef, useEffect } from "react";
import { generateThumbnail } from "@/lib/videoUtils";
import { formatDetailedTime } from "@/lib/timeUtils";
import logger from "@/lib/logger";
import type { Segment } from "@/components/ClipSegments";
// Represents a state of the editor for undo/redo
interface EditorState {
trimStart: number;
trimEnd: number;
splitPoints: number[];
clipSegments: Segment[];
action?: string;
}
const useVideoTrimmer = () => {
// Video element reference and state
const videoRef = useRef<HTMLVideoElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
// Preview mode state for playing only segments
const [isPreviewMode, setIsPreviewMode] = useState(false);
const [previewSegmentIndex, setPreviewSegmentIndex] = useState(0);
// Timeline state
const [thumbnails, setThumbnails] = useState<string[]>([]);
const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(0);
const [splitPoints, setSplitPoints] = useState<number[]>([]);
const [zoomLevel, setZoomLevel] = useState(1); // Start with 1x zoom level
// Clip segments state
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
// History state for undo/redo
const [history, setHistory] = useState<EditorState[]>([]);
const [historyPosition, setHistoryPosition] = useState(-1);
// Track unsaved changes
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Monitor for history changes
useEffect(() => {
if (history.length > 0) {
// For debugging - moved to console.debug
if (process.env.NODE_ENV === 'development') {
console.debug(`History state updated: ${history.length} entries, position: ${historyPosition}`);
// Log actions in history to help debug undo/redo
const actions = history.map((state, idx) =>
`${idx}: ${state.action || 'unknown'} (segments: ${state.clipSegments.length})`
);
console.debug('History actions:', actions);
}
// If there's at least one history entry and it wasn't a save operation, mark as having unsaved changes
const lastAction = history[historyPosition]?.action || '';
if (lastAction !== 'save' && lastAction !== 'save_copy' && lastAction !== 'save_segments') {
setHasUnsavedChanges(true);
}
}
}, [history, historyPosition]);
// Set up page unload warning
useEffect(() => {
// Event handler for beforeunload
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
// Standard way of showing a confirmation dialog before leaving
const message = 'Your edits will get lost if you leave the page. Do you want to continue?';
e.preventDefault();
e.returnValue = message; // Chrome requires returnValue to be set
return message; // For other browsers
}
};
// Add event listener
window.addEventListener('beforeunload', handleBeforeUnload);
// Clean up
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [hasUnsavedChanges]);
// Initialize video event listeners
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleLoadedMetadata = () => {
setDuration(video.duration);
setTrimEnd(video.duration);
// Generate placeholders and create initial segment
const initializeEditor = async () => {
// Generate thumbnail for initial segment
const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
// Create an initial segment that spans the entire video
const initialSegment: Segment = {
id: 1,
name: "segment",
startTime: 0,
endTime: video.duration,
thumbnail: segmentThumbnail
};
// Initialize history state with the full-length segment
const initialState: EditorState = {
trimStart: 0,
trimEnd: video.duration,
splitPoints: [],
clipSegments: [initialSegment]
};
setHistory([initialState]);
setHistoryPosition(0);
setClipSegments([initialSegment]);
// Generate timeline thumbnails
const count = 6;
const interval = video.duration / count;
const placeholders: string[] = [];
for (let i = 0; i < count; i++) {
const time = interval * i + interval / 2;
const thumbnail = await generateThumbnail(video, time);
placeholders.push(thumbnail);
}
setThumbnails(placeholders);
};
initializeEditor();
};
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};
const handlePlay = () => {
// Only update isPlaying if we're not in preview mode
if (!isPreviewMode) {
setIsPlaying(true);
}
};
const handlePause = () => {
// Only update isPlaying if we're not in preview mode
if (!isPreviewMode) {
setIsPlaying(false);
}
};
const handleEnded = () => {
setIsPlaying(false);
video.currentTime = trimStart;
};
// Add event listeners
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
return () => {
// Remove event listeners
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
};
}, [isPreviewMode]);
// Play/pause video
const playPauseVideo = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
// If at the end of the trim range, reset to the beginning
if (video.currentTime >= trimEnd) {
video.currentTime = trimStart;
}
video.play()
.then(() => {
// Play started successfully
})
.catch(err => {
console.error("Error starting playback:", err);
setIsPlaying(false); // Reset state if play failed
});
}
};
// Seek to a specific time
const seekVideo = (time: number) => {
const video = videoRef.current;
if (!video) return;
// Track if the video was playing before seeking
const wasPlaying = !video.paused;
// Store current preview mode state to preserve it
const wasInPreviewMode = isPreviewMode;
// Update the video position
video.currentTime = time;
setCurrentTime(time);
// Find segment at this position for preview mode playback
if (wasInPreviewMode) {
const segmentAtPosition = clipSegments.find(
seg => time >= seg.startTime && time <= seg.endTime
);
if (segmentAtPosition) {
// Update the active segment index in preview mode
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const newSegmentIndex = orderedSegments.findIndex(seg => seg.id === segmentAtPosition.id);
if (newSegmentIndex !== -1) {
setPreviewSegmentIndex(newSegmentIndex);
}
}
}
// Resume playback in two scenarios:
// 1. If it was playing before (regular mode)
// 2. If we're in preview mode (regardless of previous state)
if (wasPlaying || wasInPreviewMode) {
// Ensure preview mode stays on if it was on before
if (wasInPreviewMode) {
setIsPreviewMode(true);
}
// Play immediately without delay
video.play()
.then(() => {
setIsPlaying(true); // Update state to reflect we're playing
// "Resumed playback after seeking in " + (wasInPreviewMode ? "preview" : "regular") + " mode"
})
.catch(err => {
console.error("Error resuming playback:", err);
setIsPlaying(false);
});
}
};
// Save the current state to history with a debounce buffer
// This helps prevent multiple rapid saves for small adjustments
const saveState = (action?: string) => {
// Deep clone to ensure state is captured correctly
const newState: EditorState = {
trimStart,
trimEnd,
splitPoints: [...splitPoints],
clipSegments: JSON.parse(JSON.stringify(clipSegments)), // Deep clone to avoid reference issues
action: action || 'manual_save' // Track the action that triggered this save
};
// Check if state is significantly different from last saved state
const lastState = history[historyPosition];
// Helper function to compare segments deeply
const haveSegmentsChanged = () => {
if (!lastState || lastState.clipSegments.length !== newState.clipSegments.length) {
return true; // Different length means significant change
}
// Compare each segment's start and end times
for (let i = 0; i < newState.clipSegments.length; i++) {
const oldSeg = lastState.clipSegments[i];
const newSeg = newState.clipSegments[i];
if (!oldSeg || !newSeg) return true;
// Check if any time values changed by more than 0.001 seconds (1ms)
if (Math.abs(oldSeg.startTime - newSeg.startTime) > 0.001 ||
Math.abs(oldSeg.endTime - newSeg.endTime) > 0.001) {
return true;
}
}
return false; // No significant changes found
};
const isSignificantChange = !lastState ||
lastState.trimStart !== newState.trimStart ||
lastState.trimEnd !== newState.trimEnd ||
lastState.splitPoints.length !== newState.splitPoints.length ||
haveSegmentsChanged();
// Additionally, check if there's an explicit action from a UI event
const hasExplicitActionFlag = newState.action !== undefined;
// Only proceed if this is a significant change or if explicitly requested
if (isSignificantChange || hasExplicitActionFlag) {
// Get the current position to avoid closure issues
const currentPosition = historyPosition;
// Use functional updates to ensure we're working with the latest state
setHistory(prevHistory => {
// If we're not at the end of history, truncate
if (currentPosition < prevHistory.length - 1) {
const newHistory = prevHistory.slice(0, currentPosition + 1);
return [...newHistory, newState];
} else {
// Just append to current history
return [...prevHistory, newState];
}
});
// Update position using functional update
setHistoryPosition(prev => {
const newPosition = prev + 1;
// "Saved state to history position", newPosition)
return newPosition;
});
} else {
// logger.debug("Skipped non-significant state save");
}
};
// Listen for trim handle update events
useEffect(() => {
const handleTrimUpdate = (e: CustomEvent) => {
if (e.detail) {
const { time, isStart, recordHistory, action } = e.detail;
if (isStart) {
setTrimStart(time);
} else {
setTrimEnd(time);
}
// Only record in history if explicitly requested
if (recordHistory) {
// Use a small timeout to ensure the state is updated
setTimeout(() => {
saveState(action || (isStart ? 'adjust_trim_start' : 'adjust_trim_end'));
}, 10);
}
}
};
document.addEventListener('update-trim', handleTrimUpdate as EventListener);
return () => {
document.removeEventListener('update-trim', handleTrimUpdate as EventListener);
};
}, []);
// Listen for segment update events and split-at-time events
useEffect(() => {
const handleUpdateSegments = (e: CustomEvent) => {
if (e.detail && e.detail.segments) {
// Check if this is a significant change that should be recorded in history
// Default to true to ensure all segment changes are recorded
const isSignificantChange = e.detail.recordHistory !== false;
// Get the action type if provided
const actionType = e.detail.action || 'update_segments';
// Log the update details
logger.debug(`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? "true" : "false"}`);
// Update segment state immediately for UI feedback
setClipSegments(e.detail.segments);
// Always save state to history for non-intermediate actions
if (isSignificantChange) {
// A slight delay helps avoid race conditions but we need to
// ensure we capture the state properly
setTimeout(() => {
// Deep clone to ensure state is captured correctly
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
// Create a complete state snapshot
const stateWithAction: EditorState = {
trimStart,
trimEnd,
splitPoints: [...splitPoints],
clipSegments: segmentsClone,
action: actionType // Store the action type in the state
};
// Get the current history position to ensure we're using the latest value
const currentHistoryPosition = historyPosition;
// Update history with the functional pattern to avoid stale closure issues
setHistory(prevHistory => {
// If we're not at the end of the history, truncate
if (currentHistoryPosition < prevHistory.length - 1) {
const newHistory = prevHistory.slice(0, currentHistoryPosition + 1);
return [...newHistory, stateWithAction];
} else {
// Just append to current history
return [...prevHistory, stateWithAction];
}
});
// Ensure the historyPosition is updated to the correct position
setHistoryPosition(prev => {
const newPosition = prev + 1;
logger.debug(`Saved state with action: ${actionType} to history position ${newPosition}`);
return newPosition;
});
}, 20); // Slightly increased delay to ensure state updates are complete
} else {
logger.debug(`Skipped saving state to history for action: ${actionType} (recordHistory=false)`);
}
}
};
const handleSplitSegment = async (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail &&
typeof customEvent.detail.time === 'number' &&
typeof customEvent.detail.segmentId === 'number') {
// Get the time and segment ID from the event
const timeToSplit = customEvent.detail.time;
const segmentId = customEvent.detail.segmentId;
// Move the current time to the split position
seekVideo(timeToSplit);
// Find the segment to split
const segmentToSplit = clipSegments.find(seg => seg.id === segmentId);
if (!segmentToSplit) return;
// Make sure the split point is within the segment
if (timeToSplit <= segmentToSplit.startTime || timeToSplit >= segmentToSplit.endTime) {
return; // Can't split outside segment boundaries
}
// Create two new segments from the split
const newSegments = [...clipSegments];
// Remove the original segment
const segmentIndex = newSegments.findIndex(seg => seg.id === segmentId);
if (segmentIndex === -1) return;
newSegments.splice(segmentIndex, 1);
// Create first half of the split segment - no thumbnail needed
const firstHalf: Segment = {
id: Date.now(),
name: `${segmentToSplit.name}-A`,
startTime: segmentToSplit.startTime,
endTime: timeToSplit,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Create second half of the split segment - no thumbnail needed
const secondHalf: Segment = {
id: Date.now() + 1,
name: `${segmentToSplit.name}-B`,
startTime: timeToSplit,
endTime: segmentToSplit.endTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Add the new segments
newSegments.push(firstHalf, secondHalf);
// Sort segments by start time
newSegments.sort((a, b) => a.startTime - b.startTime);
// Update state
setClipSegments(newSegments);
saveState('split_segment');
}
};
// Handle delete segment event
const handleDeleteSegment = async (e: Event) => {
const customEvent = e as CustomEvent;
if (customEvent.detail && typeof customEvent.detail.segmentId === 'number') {
const segmentId = customEvent.detail.segmentId;
// Find and remove the segment
const newSegments = clipSegments.filter(segment => segment.id !== segmentId);
if (newSegments.length !== clipSegments.length) {
// If all segments are deleted, create a new full video segment
if (newSegments.length === 0 && videoRef.current) {
// Create a new default segment that spans the entire video
// No need to generate a thumbnail - we'll use dynamic colors
const defaultSegment: Segment = {
id: Date.now(),
name: "segment",
startTime: 0,
endTime: videoRef.current.duration,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
// Reset the trim points as well
setTrimStart(0);
setTrimEnd(videoRef.current.duration);
setSplitPoints([]);
setClipSegments([defaultSegment]);
} else {
// Just update the segments normally
setClipSegments(newSegments);
}
saveState('delete_segment');
}
}
};
document.addEventListener('update-segments', handleUpdateSegments as EventListener);
document.addEventListener('split-segment', handleSplitSegment as EventListener);
document.addEventListener('delete-segment', handleDeleteSegment as EventListener);
return () => {
document.removeEventListener('update-segments', handleUpdateSegments as EventListener);
document.removeEventListener('split-segment', handleSplitSegment as EventListener);
document.removeEventListener('delete-segment', handleDeleteSegment as EventListener);
};
}, [clipSegments, duration]);
// Preview mode effect to handle playing only segments
useEffect(() => {
if (!isPreviewMode || !videoRef.current) return;
// logger.debug("Preview mode effect running, previewSegmentIndex:", previewSegmentIndex);
// Sort segments by start time
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Check if we've reached the end of the current segment
const handleSegmentPlayback = () => {
if (!isPreviewMode || !videoRef.current) return;
const currentSegment = orderedSegments[previewSegmentIndex];
if (!currentSegment) return;
// Make sure we're inside the current segment
const currentTime = videoRef.current.currentTime;
// If the current time is before the segment start, move to the segment start
if (currentTime < currentSegment.startTime) {
videoRef.current.currentTime = currentSegment.startTime;
return;
}
// If we've reached the end of the current segment
if (currentTime >= currentSegment.endTime - 0.05) { // Slightly before the end to ensure smooth transition
// Move to the next segment if available
if (previewSegmentIndex < orderedSegments.length - 1) {
const nextSegment = orderedSegments[previewSegmentIndex + 1];
// Play through the next segment immediately
videoRef.current.currentTime = nextSegment.startTime;
setPreviewSegmentIndex(previewSegmentIndex + 1);
logger.debug("Moving to next segment in preview:", nextSegment.name,
"from", formatDetailedTime(currentSegment.endTime),
"to", formatDetailedTime(nextSegment.startTime));
// Ensure playback continues
videoRef.current.play().catch(err => {
console.error("Error continuing preview playback:", err);
});
} else {
// End of all segments, loop back to the first segment
logger.debug("End of all segments in preview, looping back to first");
videoRef.current.currentTime = orderedSegments[0].startTime;
setPreviewSegmentIndex(0);
// Ensure playback continues
videoRef.current.play().catch(err => {
console.error("Error restarting preview playback:", err);
});
}
}
};
// Add event listener for timeupdate to check segment boundaries
videoRef.current.addEventListener('timeupdate', handleSegmentPlayback);
return () => {
if (videoRef.current) {
videoRef.current.removeEventListener('timeupdate', handleSegmentPlayback);
}
};
}, [isPreviewMode, previewSegmentIndex, clipSegments]);
// Handle trim start change
const handleTrimStartChange = (time: number) => {
setTrimStart(time);
saveState('adjust_trim_start');
};
// Handle trim end change
const handleTrimEndChange = (time: number) => {
setTrimEnd(time);
saveState('adjust_trim_end');
};
// Handle split at current position
const handleSplit = async () => {
if (!videoRef.current) return;
// Add current time to split points if not already present
if (!splitPoints.includes(currentTime)) {
const newSplitPoints = [...splitPoints, currentTime].sort((a, b) => a - b);
setSplitPoints(newSplitPoints);
// Generate segments based on split points
const newSegments: Segment[] = [];
let startTime = 0;
for (let i = 0; i <= newSplitPoints.length; i++) {
const endTime = i < newSplitPoints.length ? newSplitPoints[i] : duration;
if (startTime < endTime) {
// No need to generate thumbnails - we'll use dynamic colors
newSegments.push({
id: Date.now() + i,
name: `Segment ${i + 1}`,
startTime,
endTime,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
});
startTime = endTime;
}
}
setClipSegments(newSegments);
saveState('create_split_points');
}
};
// Handle reset of all edits
const handleReset = async () => {
setTrimStart(0);
setTrimEnd(duration);
setSplitPoints([]);
// Create a new default segment that spans the entire video
if (!videoRef.current) return;
// No need to generate thumbnails - we'll use dynamic colors
const defaultSegment: Segment = {
id: Date.now(),
name: "segment",
startTime: 0,
endTime: duration,
thumbnail: '' // Empty placeholder - we'll use dynamic colors instead
};
setClipSegments([defaultSegment]);
saveState('reset_all');
};
// Handle undo
const handleUndo = () => {
if (historyPosition > 0) {
const previousState = history[historyPosition - 1];
logger.debug(`** UNDO ** to position ${historyPosition - 1}, action: ${previousState.action}, segments: ${previousState.clipSegments.length}`);
// Log segment details to help debug
logger.debug("Segment details after undo:", previousState.clipSegments.map(seg =>
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
));
// Apply the previous state with deep cloning to avoid reference issues
setTrimStart(previousState.trimStart);
setTrimEnd(previousState.trimEnd);
setSplitPoints([...previousState.splitPoints]);
setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments)));
setHistoryPosition(historyPosition - 1);
} else {
logger.debug("Cannot undo: at earliest history position");
}
};
// Handle redo
const handleRedo = () => {
if (historyPosition < history.length - 1) {
const nextState = history[historyPosition + 1];
logger.debug(`** REDO ** to position ${historyPosition + 1}, action: ${nextState.action}, segments: ${nextState.clipSegments.length}`);
// Log segment details to help debug
logger.debug("Segment details after redo:", nextState.clipSegments.map(seg =>
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
));
// Apply the next state with deep cloning to avoid reference issues
setTrimStart(nextState.trimStart);
setTrimEnd(nextState.trimEnd);
setSplitPoints([...nextState.splitPoints]);
setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments)));
setHistoryPosition(historyPosition + 1);
} else {
logger.debug("Cannot redo: at latest history position");
}
};
// Handle playing the preview of all segments
const handlePreview = () => {
const video = videoRef.current;
if (!video || clipSegments.length === 0) return;
// If preview is already active, turn it off
if (isPreviewMode) {
setIsPreviewMode(false);
// Always pause the video when exiting preview mode
video.pause();
setIsPlaying(false);
logger.debug("Exiting preview mode - video paused");
return;
}
// If normal playback is happening, remember that state but pause it
const wasPlaying = isPlaying;
if (wasPlaying) {
video.pause();
}
// Sort segments by start time to ensure proper playback order
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
if (orderedSegments.length === 0) return;
// Set the preview mode flag before starting playback
setIsPreviewMode(true);
logger.debug("Entering preview mode");
// Set the first segment as the current one in the preview sequence
setPreviewSegmentIndex(0);
// Start preview mode by playing the first segment
video.currentTime = orderedSegments[0].startTime;
// Force a small delay before playing to ensure the preview mode state is set
setTimeout(() => {
if (videoRef.current) {
videoRef.current.play()
.then(() => {
logger.debug("Preview started successfully");
setIsPlaying(true);
})
.catch(err => {
console.error("Error starting preview:", err);
setIsPreviewMode(false); // Reset preview mode if play fails
setIsPlaying(false);
});
}
}, 50); // Short delay to ensure state updates have processed
};
// Handle zoom level change
const handleZoomChange = (level: number) => {
setZoomLevel(level);
};
// Handle play/pause of the full video
const handlePlay = () => {
const video = videoRef.current;
if (!video) return;
// If in preview mode, exit it before toggling normal play
if (isPreviewMode) {
setIsPreviewMode(false);
// Don't immediately start playing when exiting preview mode
// Just update the state and return
setIsPlaying(false);
video.pause();
return;
}
if (isPlaying) {
// Pause the video
video.pause();
setIsPlaying(false);
} else {
// Play the video from current position with proper promise handling
video.play()
.then(() => {
setIsPlaying(true);
})
.catch(err => {
console.error("Error playing video:", err);
setIsPlaying(false); // Reset state if play failed
});
}
};
// Toggle mute state
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(!isMuted);
};
// Handle save action
const handleSave = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving
const saveData = {
type: "save",
segments: sortedSegments.map(segment => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
};
// Display JSON in alert (for demonstration purposes)
alert(JSON.stringify(saveData, null, 2));
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
if (process.env.NODE_ENV === 'development') {
console.debug("Changes saved - reset unsaved changes flag");
}
// Save to history with special "save" action to mark saved state
saveState('save');
// In a real implementation, this would make a POST request to save the data
// logger.debug("Save data:", saveData);
};
// Handle save a copy action
const handleSaveACopy = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving as a copy
const saveData = {
type: "save_as_a_copy",
segments: sortedSegments.map(segment => ({
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
};
// Display JSON in alert (for demonstration purposes)
alert(JSON.stringify(saveData, null, 2));
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
if (process.env.NODE_ENV === 'development') {
console.debug("Changes saved as copy - reset unsaved changes flag");
}
// Save to history with special "save_copy" action to mark saved state
saveState('save_copy');
};
// Handle save segments individually action
const handleSaveSegments = () => {
// Sort segments chronologically by start time before saving
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
// Create the JSON data for saving individual segments
const saveData = {
type: "save_segments",
segments: sortedSegments.map(segment => ({
name: segment.name,
startTime: formatDetailedTime(segment.startTime),
endTime: formatDetailedTime(segment.endTime)
}))
};
// Display JSON in alert (for demonstration purposes)
alert(JSON.stringify(saveData, null, 2));
// Mark as saved - no unsaved changes
setHasUnsavedChanges(false);
// Debug message
logger.debug("All segments saved individually - reset unsaved changes flag");
// Save to history with special "save_segments" action to mark saved state
saveState('save_segments');
};
return {
videoRef,
currentTime,
duration,
isPlaying,
isPreviewMode,
thumbnails,
trimStart,
trimEnd,
splitPoints,
zoomLevel,
clipSegments,
historyPosition,
history,
isMuted,
hasUnsavedChanges, // Add unsaved changes flag to the return object
playPauseVideo,
seekVideo,
handleTrimStartChange,
handleTrimEndChange,
handleSplit,
handleReset,
handleUndo,
handleRedo,
handlePreview,
handlePlay,
handleZoomChange,
toggleMute,
handleSave,
handleSaveACopy,
handleSaveSegments,
};
};
export default useVideoTrimmer;
@@ -0,0 +1,786 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground: 20 14.3% 4.1%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--primary: 207 90% 54%;
--primary-foreground: 211 100% 99%;
--secondary: 30 84% 54%; /* Changed from red (0) to orange (30) */
--secondary-foreground: 60 9.1% 97.8%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
@layer base {
* {
@apply border-border;
}
}
/* Video Player Styles */
.video-player {
position: relative;
width: 100%;
background-color: #000;
overflow: hidden;
border-radius: 0.5rem;
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
padding: 1rem;
display: flex;
flex-direction: column;
}
.video-current-time {
color: #fff;
font-weight: 500;
}
.video-progress {
position: relative;
height: 4px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 2px;
margin-bottom: 1rem;
}
.video-progress-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: hsl(var(--primary));
border-radius: 2px;
}
.video-scrubber {
position: absolute;
width: 12px;
height: 12px;
margin-left: -6px;
background-color: white;
border-radius: 50%;
top: -4px;
}
/* Play/Pause indicator for video player */
.video-player-container {
position: relative;
overflow: hidden;
}
.play-pause-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70px;
height: 70px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 20;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
background-position: center;
background-repeat: no-repeat;
}
.play-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E");
}
.pause-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E");
}
/* Only show play/pause indicator on hover */
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
/* Timeline Styles */
.timeline-scroll-container {
height: 6rem;
border-radius: 0.375rem;
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 0.75rem;
background-color: #EEE; /* Very light gray background */
position: relative;
}
.timeline-container {
position: relative;
background-color: #EEE; /* Very light gray background */
height: 6rem;
width: 100%;
cursor: pointer;
transition: width 0.3s ease;
}
.timeline-marker {
position: absolute;
top: -10px;
height: calc(100% + 10px);
width: 2px;
background-color: red;
z-index: 100; /* Highest z-index to stay on top of everything */
pointer-events: none; /* Allow clicks to pass through to segments underneath */
box-shadow: 0 0 4px rgba(255, 0, 0, 0.5); /* Add subtle glow effect */
}
.trim-line-marker {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
z-index: 10;
}
.trim-handle {
width: 8px;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
position: absolute;
top: 0;
bottom: 0;
cursor: ew-resize;
z-index: 15;
}
.trim-handle.left {
left: -4px;
}
.trim-handle.right {
right: -4px;
}
.timeline-thumbnail {
height: 100%;
border-right: 1px solid rgba(0, 0, 0, 0.1);
position: relative;
display: inline-block;
background-size: cover;
background-position: center;
}
.split-point {
position: absolute;
width: 2px;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
top: 0;
bottom: 0;
z-index: 5;
}
/* Clip Segment styles */
.clip-segment {
position: absolute;
height: 95%;
top: 0;
border-radius: 4px;
background-size: cover;
background-position: center;
background-blend-mode: soft-light;
/* Border is now set in the color-specific rules */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
cursor: grab;
user-select: none;
transition: box-shadow 0.2s, transform 0.1s;
/* Original z-index for stacking order based on segment ID */
z-index: 15;
}
/* No background colors for segments, just borders with 2-color scheme */
.clip-segment:nth-child(odd), .segment-color-1, .segment-color-3, .segment-color-5, .segment-color-7 {
background-color: transparent;
border: 2px solid rgba(0, 123, 255, 0.9); /* Blue border */
}
.clip-segment:nth-child(even), .segment-color-2, .segment-color-4, .segment-color-6, .segment-color-8 {
background-color: transparent;
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
}
.clip-segment:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: translateY(-1px);
filter: brightness(1.1);
}
.clip-segment:active {
cursor: grabbing;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transform: translateY(0);
}
.clip-segment.selected {
border-width: 3px; /* Make border thicker instead of changing color */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 25;
filter: brightness(1.2);
}
.clip-segment-info {
background-color: rgba(226, 230, 234, 0.9); /* Light gray background */
color: #000000; /* Pure black text */
padding: 6px 8px;
font-size: 0.7rem;
position: absolute;
top: 0;
left: 0;
width: 100%;
border-radius: 4px 4px 0 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 2px;
}
.clip-segment-name {
font-weight: bold;
color: #000000; /* Pure black text */
}
.clip-segment-time {
font-size: 0.65rem;
color: #000000; /* Pure black text */
}
.clip-segment-duration {
font-size: 0.65rem;
color: #000000; /* Pure black text */
background: rgba(179, 217, 255, 0.4); /* Light blue background */
padding: 1px 4px;
border-radius: 2px;
display: inline-block;
margin-top: 2px;
}
.clip-segment-handle {
position: absolute;
width: 8px;
top: 0;
bottom: 0;
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
cursor: ew-resize;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
.clip-segment-handle::after {
content: "↔";
color: white;
font-size: 12px;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
}
.clip-segment-handle.left {
left: 0;
}
.clip-segment-handle.right {
right: 0;
}
.clip-segment-handle:hover {
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
width: 10px;
}
/* Zoom Slider */
input[type="range"] {
-webkit-appearance: none;
height: 6px;
background: #E0E0E0;
border-radius: 3px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: rgba(0, 123, 255, 0.9); /* Primary blue color */
cursor: pointer;
}
/* Tooltip styles */
[data-tooltip] {
position: relative;
cursor: pointer;
}
[data-tooltip]::before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 0.8rem;
white-space: nowrap;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]::after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
margin-bottom: 0px;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
/* Only show tooltips on devices with mouse hover capability */
@media (hover: hover) and (pointer: fine) {
[data-tooltip]:hover::before,
[data-tooltip]:hover::after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips (simple hover labels) on touch devices */
@media (pointer: coarse) {
[data-tooltip]::before,
[data-tooltip]::after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
/* Fix for buttons with disabled state */
button[disabled][data-tooltip]::before,
button[disabled][data-tooltip]::after {
opacity: 0.5;
}
/* Custom tooltip for action buttons - completely different approach */
.tooltip-action-btn {
position: relative;
}
.tooltip-action-btn[data-tooltip]::before,
.tooltip-action-btn[data-tooltip]::after {
/* Reset standard tooltip styles first */
opacity: 0;
visibility: hidden;
position: absolute;
pointer-events: none;
transition: all 0.3s ease;
}
.tooltip-action-btn[data-tooltip]::before {
content: attr(data-tooltip);
background-color: rgba(0, 0, 0, 0.8);
color: white;
font-size: 12px;
padding: 4px 8px;
border-radius: 3px;
white-space: nowrap;
/* Position below the button */
bottom: -35px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
}
.tooltip-action-btn[data-tooltip]::after {
content: "";
border-width: 5px;
border-style: solid;
border-color: transparent transparent rgba(0, 0, 0, 0.8) transparent;
/* Position the arrow */
bottom: -15px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
}
/* Only show tooltips on devices with mouse hover capability */
@media (hover: hover) and (pointer: fine) {
.tooltip-action-btn:hover[data-tooltip]::before,
.tooltip-action-btn:hover[data-tooltip]::after {
opacity: 1;
visibility: visible;
}
}
/* Ensure tooltip container has proper space */
/* Segment tooltip styles */
.segment-tooltip {
background-color: rgba(179, 217, 255, 0.95); /* Light blue color */
color: #000000; /* Pure black text */
border-radius: 4px;
padding: 6px; /* Regular padding now that we have custom tooltips */
min-width: 140px; /* Increased width to accommodate the new delete button */
z-index: 1000; /* Increased z-index */
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
.segment-tooltip::after {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(179, 217, 255, 0.95); /* Light blue color */
}
.tooltip-time {
font-size: 0.85rem;
font-weight: bold;
text-align: center;
margin-bottom: 6px;
color: #000000; /* Pure black text */
}
.tooltip-actions {
display: flex;
justify-content: space-between;
gap: 5px;
position: relative;
}
.tooltip-action-btn {
background-color: rgba(0, 123, 255, 0.2); /* Light blue background */
border: none;
border-radius: 3px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 6px;
transition: background-color 0.2s;
min-width: 20px !important;
}
.tooltip-action-btn:hover {
background-color: rgba(0, 123, 255, 0.4); /* Slightly darker on hover */
}
.tooltip-action-btn svg {
width: 100%;
height: 100%;
stroke: currentColor;
}
/* Adjust for the hand icons specifically */
.tooltip-action-btn.set-in svg,
.tooltip-action-btn.set-out svg {
width: 100%;
height: 100%;
margin: 0 auto;
fill: currentColor;
stroke: none;
}
/* Empty space tooltip styling */
.empty-space-tooltip {
background-color: white;
border-radius: 6px;
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15);
padding: 8px;
z-index: 50;
min-width: 120px;
text-align: center;
position: relative;
}
.empty-space-tooltip::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
border-width: 8px 8px 0;
border-style: solid;
border-color: white transparent transparent;
}
.tooltip-action-btn.new-segment {
width: auto;
padding: 6px 10px;
display: flex;
align-items: center;
gap: 5px;
}
.tooltip-btn-text {
font-size: 0.8rem;
white-space: nowrap;
color: #000000; /* Pure black text */
}
.icon-new-segment {
width: 20px;
height: 20px;
}
/* Zoom dropdown styling */
.zoom-dropdown-container {
position: relative;
}
.zoom-button {
display: flex;
align-items: center;
gap: 6px;
background-color: rgba(108, 117, 125, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.zoom-button:hover {
background-color: rgba(108, 117, 125, 1);
}
.zoom-dropdown {
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
max-height: 300px;
overflow-y: auto;
}
.zoom-option {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.zoom-option:hover {
background-color: rgba(0, 123, 255, 0.1);
}
.zoom-option.selected {
background-color: rgba(0, 123, 255, 0.2);
font-weight: 500;
}
/* Save buttons styling */
.save-button, .save-copy-button {
background-color: rgba(0, 123, 255, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.save-button:hover, .save-copy-button:hover {
background-color: rgba(0, 123, 255, 1);
}
.save-copy-button {
background-color: rgba(108, 117, 125, 0.8);
}
.save-copy-button:hover {
background-color: rgba(108, 117, 125, 1);
}
/* Time navigation input styling */
.time-nav-label {
font-weight: 500;
font-size: 0.9rem;
}
.time-input {
padding: 6px 10px;
border-radius: 4px;
border: 1px solid #ccc;
width: 150px;
font-family: monospace;
}
.time-button-group {
display: flex;
gap: 5px;
}
.time-button {
background-color: rgba(108, 117, 125, 0.8);
color: white;
border: none;
border-radius: 4px;
padding: 6px 8px;
font-size: 0.8rem;
cursor: pointer;
transition: background-color 0.2s;
}
.time-button:hover {
background-color: rgba(108, 117, 125, 1);
}
/* Timeline navigation and zoom controls responsiveness */
.timeline-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
padding: 12px;
background-color: #f5f5f5;
border-radius: 6px;
margin-top: 15px;
}
.time-navigation {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.controls-right {
display: flex;
align-items: center;
gap: 10px;
}
/* Media queries for responsive design */
@media (max-width: 768px) {
.timeline-controls {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.controls-right {
margin-top: 10px;
width: 100%;
justify-content: flex-start;
text-align: center;
align-items: center;
justify-content: center;
}
}
/* Timeline header styling */
.timeline-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.timeline-title {
font-weight: bold;
margin-right: 20px;
}
.timeline-title-text {
font-size: 1.1rem;
}
.current-time, .duration-time {
white-space: nowrap;
}
.time-code {
font-family: monospace;
font-weight: 500;
}
@media (max-width: 480px) {
.timeline-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.time-navigation {
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.time-button-group {
width: 100%;
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.controls-right {
flex-wrap: wrap;
gap: 8px;
}
.save-button, .save-copy-button {
margin-top: 8px;
width: 100%;
}
.zoom-dropdown-container {
width: 100%;
}
.zoom-button {
width: 100%;
justify-content: center;
}
}
@@ -0,0 +1,31 @@
/**
* A consistent logger utility that only outputs debug messages in development
* but always shows errors, warnings, and info messages.
*/
const logger = {
/**
* Logs debug messages only in development environment
*/
debug: (...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
console.debug(...args);
}
},
/**
* Always logs error messages
*/
error: (...args: any[]) => console.error(...args),
/**
* Always logs warning messages
*/
warn: (...args: any[]) => console.warn(...args),
/**
* Always logs info messages
*/
info: (...args: any[]) => console.info(...args)
};
export default logger;
@@ -0,0 +1,57 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query";
async function throwIfResNotOk(res: Response) {
if (!res.ok) {
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
}
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined,
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include",
});
await throwIfResNotOk(res);
return res;
}
type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior;
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const res = await fetch(queryKey[0] as string, {
credentials: "include",
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;
}
await throwIfResNotOk(res);
return await res.json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
retry: false,
},
mutations: {
retry: false,
},
},
});
@@ -0,0 +1,34 @@
/**
* Format seconds to HH:MM:SS.mmm format with millisecond precision
*/
export const formatDetailedTime = (seconds: number): string => {
if (isNaN(seconds)) return "00:00:00.000";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const milliseconds = Math.round((seconds % 1) * 1000);
const formattedHours = String(hours).padStart(2, "0");
const formattedMinutes = String(minutes).padStart(2, "0");
const formattedSeconds = String(remainingSeconds).padStart(2, "0");
const formattedMilliseconds = String(milliseconds).padStart(3, "0");
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
};
/**
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
*/
export const formatTime = (seconds: number): string => {
// Use the detailed format instead of the old MM:SS format
return formatDetailedTime(seconds);
};
/**
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
*/
export const formatLongTime = (seconds: number): string => {
// Use the detailed format
return formatDetailedTime(seconds);
};
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,50 @@
/**
* Generate a solid color background for a segment
* Returns a CSS color based on the segment position
*/
export const generateSolidColor = (
time: number,
duration: number
): string => {
// Use the time position to create different colors
// This gives each segment a different color without needing an image
const position = Math.min(Math.max(time / (duration || 1), 0), 1);
// Calculate color based on position
// Use an extremely light blue-based color palette
const hue = 210; // Blue base
const saturation = 40 + Math.floor(position * 20); // 40-60% (less saturated)
const lightness = 85 + Math.floor(position * 8); // 85-93% (extremely light)
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
/**
* Legacy function kept for compatibility
* Now returns a data URL for a solid color square instead of a video thumbnail
*/
export const generateThumbnail = async (
videoElement: HTMLVideoElement,
time: number
): Promise<string> => {
return new Promise((resolve) => {
// Create a small canvas for the solid color
const canvas = document.createElement('canvas');
canvas.width = 10; // Much smaller - we only need a color
canvas.height = 10;
const ctx = canvas.getContext('2d');
if (ctx) {
// Get the solid color based on time
const color = generateSolidColor(time, videoElement.duration);
// Fill with solid color
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Convert to data URL (much smaller now)
const dataUrl = canvas.toDataURL('image/png', 0.5);
resolve(dataUrl);
});
};
@@ -0,0 +1,35 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
if (typeof window !== 'undefined') {
window.MEDIA_DATA = {
videoUrl: "",
mediaId: ""
};
}
declare global {
interface Window {
MEDIA_DATA: {
videoUrl: string;
mediaId: string;
};
seekToFunction?: (time: number) => void;
}
}
// Mount the components when the DOM is ready
const mountComponents = () => {
const trimContainer = document.getElementById("video-editor-trim-root");
if (trimContainer) {
const trimRoot = createRoot(trimContainer);
trimRoot.render(<App />);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountComponents);
} else {
mountComponents();
}
@@ -0,0 +1,118 @@
// API service for video trimming operations
interface TrimVideoRequest {
segments: {
startTime: string;
endTime: string;
name?: string;
}[];
saveAsCopy?: boolean;
saveIndividualSegments?: boolean;
}
interface TrimVideoResponse {
msg: string;
url_redirect: string;
status?: number; // HTTP status code for success/error
error?: string; // Error message if status is not 200
}
// Helper function to simulate delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// For now, we'll use a mock API that returns a promise
// This can be replaced with actual API calls later
export const trimVideo = async (
mediaId: string,
data: TrimVideoRequest
): Promise<TrimVideoResponse> => {
try {
// Attempt the real API call
const response = await fetch(`/api/v1/media/${mediaId}/trim_video`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
// For error responses, return with error status and message
if (response.status === 400) {
// Handle 400 Bad Request - return with error details
try {
// Try to get error details from response
const errorData = await response.json();
return {
status: 400,
error: errorData.error || "An error occurred during processing",
msg: "Video Processing Error",
url_redirect: ""
};
} catch (parseError) {
// If can't parse response JSON, return generic error
return {
status: 400,
error: "An error occurred during video processing",
msg: "Video Processing Error",
url_redirect: ""
};
}
} else if (response.status !== 404) {
// Handle other error responses
try {
// Try to get error details from response
const errorData = await response.json();
return {
status: response.status,
error: errorData.error || "An error occurred during processing",
msg: "Video Processing Error",
url_redirect: ""
};
} catch (parseError) {
// If can't parse response JSON, return generic error
return {
status: response.status,
error: "An error occurred during video processing",
msg: "Video Processing Error",
url_redirect: ""
};
}
} else {
// If endpoint not ready (404), return mock success response
await delay(1500); // Simulate 1.5 second server delay
return {
status: 200, // Mock success status
msg: "Video Processed Successfully", // Updated per requirements
url_redirect: `./view?m=${mediaId}`
};
}
}
// Successful response
const jsonResponse = await response.json();
return {
status: 200,
msg: "Video Processed Successfully", // Ensure the success message is correct
url_redirect: jsonResponse.url_redirect || `./view?m=${mediaId}`,
...jsonResponse
};
} catch (error) {
// For any fetch errors, return mock success response with delay
await delay(1500); // Simulate 1.5 second server delay
return {
status: 200, // Mock success status
msg: "Video Processed Successfully", // Consistent with requirements
url_redirect: `./view?m=${mediaId}`
};
}
/* Mock implementation that simulates network latency
return new Promise((resolve) => {
setTimeout(() => {
resolve({
msg: "Video is processing for trim",
url_redirect: `./view?m=${mediaId}`
});
}, 1500); // Simulate 1.5 second server delay
});
*/
};
@@ -0,0 +1,174 @@
#video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.clip-segments-container {
margin-top: 1rem;
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.clip-segments-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
margin-bottom: 0.75rem;
}
.segment-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 0.25rem;
margin-bottom: 0.5rem;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
}
.segment-content {
display: flex;
align-items: center;
}
.segment-thumbnail {
width: 4rem;
height: 2.25rem;
background-size: cover;
background-position: center;
border-radius: 0.25rem;
margin-right: 0.75rem;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3);
}
.segment-info {
display: flex;
flex-direction: column;
}
.segment-title {
font-weight: 500;
font-size: 0.875rem;
color: black;
}
.segment-time {
font-size: 0.75rem;
color: black;
}
.segment-duration {
font-size: 0.75rem;
margin-top: 0.25rem;
display: inline-block;
background-color: #f3f4f6;
padding: 0 0.5rem;
border-radius: 0.25rem;
color: black;
}
.segment-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.delete-button {
padding: 0.375rem;
color: #4b5563;
background-color: #e5e7eb;
border-radius: 9999px;
border: none;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
min-width: auto;
&:hover {
color: black;
background-color: #d1d5db;
}
svg {
height: 1rem;
width: 1rem;
}
}
.empty-message {
padding: 1rem;
text-align: center;
color: rgba(51, 51, 51, 0.7);
}
.segment-color-1 { background-color: rgba(59, 130, 246, 0.15); }
.segment-color-2 { background-color: rgba(16, 185, 129, 0.15); }
.segment-color-3 { background-color: rgba(245, 158, 11, 0.15); }
.segment-color-4 { background-color: rgba(239, 68, 68, 0.15); }
.segment-color-5 { background-color: rgba(139, 92, 246, 0.15); }
.segment-color-6 { background-color: rgba(236, 72, 153, 0.15); }
.segment-color-7 { background-color: rgba(6, 182, 212, 0.15); }
.segment-color-8 { background-color: rgba(250, 204, 21, 0.15); }
}
@@ -0,0 +1,317 @@
#video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.editing-tools-container {
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.flex-container {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
gap: 15px;
width: 100%;
}
.flex-container.single-row {
flex-wrap: nowrap;
}
/* Show full text on larger screens, hide short text */
.full-text {
display: inline;
}
.short-text {
display: none;
}
/* Reset text always visible by default */
.reset-text {
display: inline;
}
.button-group {
display: flex;
align-items: center;
&.play-buttons-group {
gap: 0.75rem;
justify-content: flex-start;
flex: 0 0 auto; /* Don't expand to fill space */
}
&.secondary {
gap: 0.75rem;
align-items: center;
justify-content: flex-end;
margin-left: auto; /* Push to right edge */
}
button {
display: flex;
align-items: center;
color: #333;
background: none;
border: none;
cursor: pointer;
min-width: auto;
/* Disabled hover effect as requested */
&:hover:not(:disabled) {
color: inherit;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.25rem;
}
}
}
.divider {
border-right: 1px solid #d1d5db;
height: 1.5rem;
margin: 0 0.5rem;
}
/* Style for play buttons with highlight effect */
.play-button, .preview-button {
font-weight: 600;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
min-width: 80px;
justify-content: center;
font-size: 0.875rem !important;
}
/* Completely disable ALL hover effects for play buttons */
.play-button:hover:not(:disabled), .preview-button:hover:not(:disabled) {
/* Reset everything to prevent any changes */
color: inherit !important;
transform: none !important;
font-size: 0.875rem !important;
width: auto !important;
background: none !important;
}
.play-button svg, .preview-button svg {
height: 1.5rem;
width: 1.5rem;
/* Make sure SVG scales with the button but doesn't change layout */
flex-shrink: 0;
}
/* Style for the preview mode message that replaces the play button */
.preview-mode-message {
display: flex;
align-items: center;
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
padding: 6px 12px;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
.preview-mode-message svg {
height: 1.25rem;
width: 1.25rem;
margin-right: 0.5rem;
color: #3b82f6;
}
/* Add responsive button text class */
.button-text {
margin-left: 0.25rem;
}
/* Media queries for the editing tools */
@media (max-width: 992px) {
/* Hide text for undo/redo buttons on medium screens */
.button-group.secondary .button-text {
display: none;
}
}
@media (max-width: 768px) {
/* Keep all buttons in a single row, make them more compact */
.flex-container.single-row {
justify-content: space-between;
}
.button-group {
gap: 0.5rem;
}
/* Keep font size consistent regardless of screen size */
.preview-button, .play-button {
font-size: 0.875rem !important;
}
}
@media (max-width: 640px) {
/* At this breakpoint, make preview button text shorter */
.preview-button {
min-width: auto;
}
/* Switch to short text versions */
.full-text {
display: none;
}
.short-text {
display: inline;
}
/* Hide reset text */
.reset-text {
display: none;
}
/* Ensure buttons stay in correct position */
.button-group.play-buttons-group {
flex: initial;
justify-content: flex-start;
}
.button-group.secondary {
flex: initial;
justify-content: flex-end;
}
}
@media (max-width: 576px) {
/* Keep single row, left-align play buttons, right-align controls */
.flex-container.single-row {
justify-content: space-between;
flex-wrap: nowrap;
}
/* Fix left-align for play buttons */
.button-group.play-buttons-group {
justify-content: flex-start;
flex: 0 0 auto;
}
/* Fix right-align for editing controls */
.button-group.secondary {
justify-content: flex-end;
margin-left: auto;
}
/* Reduce button padding to fit more easily */
.button-group button {
padding: 0.25rem;
}
/* Smaller preview mode message */
.preview-mode-message {
font-size: 0.8rem;
padding: 4px 8px;
}
.divider {
margin: 0 0.25rem;
}
}
/* Very small screens - maintain layout but reduce further */
@media (max-width: 480px) {
.button-group.play-buttons-group,
.button-group.secondary {
gap: 0.25rem;
}
.divider {
display: none; /* Hide divider on very small screens */
}
}
}
@@ -0,0 +1,223 @@
#video-editor-trim-root {
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
animation: modal-fade-in 0.3s ease-out;
}
@keyframes modal-fade-in {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #333;
}
.modal-close-button {
background: none;
border: none;
cursor: pointer;
color: #666;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.modal-close-button:hover {
color: #000;
}
.modal-content {
padding: 20px;
color: #333;
font-size: 1rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
padding: 16px 20px;
border-top: 1px solid #eee;
gap: 12px;
}
.modal-button {
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.modal-button-primary {
background-color: #0066cc;
color: white;
}
.modal-button-primary:hover {
background-color: #0055aa;
}
.modal-button-secondary {
background-color: #f0f0f0;
color: #333;
}
.modal-button-secondary:hover {
background-color: #e0e0e0;
}
.modal-button-danger {
background-color: #dc3545;
color: white;
}
.modal-button-danger:hover {
background-color: #bd2130;
}
/* Modal content styles */
.modal-message {
margin-bottom: 16px;
font-size: 1rem;
}
.text-center {
text-align: center;
}
.modal-spinner {
display: flex;
align-items: center;
justify-content: center;
margin: 20px 0;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top: 4px solid #0066cc;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.modal-success-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #28a745;
font-size: 2rem;
}
.modal-error-icon {
display: flex;
justify-content: center;
margin-bottom: 16px;
color: #dc3545;
font-size: 2rem;
}
.modal-choices {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.modal-choice-button {
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f8f8f8;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
text-decoration: none;
color: #333;
}
.modal-choice-button:hover {
background-color: #eee;
border-color: #ccc;
}
.modal-choice-button svg {
margin-right: 8px;
}
.centered-choice {
margin: 0 auto;
width: auto;
min-width: 220px;
background-color: #0066cc;
color: white;
}
.centered-choice:hover {
background-color: #0055aa;
}
@media (max-width: 480px) {
.modal-container {
width: 95%;
}
.modal-actions {
flex-direction: column;
}
.modal-button {
width: 100%;
}
}
}
@@ -0,0 +1,583 @@
#video-editor-trim-root {
.timeline-container-card {
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.timeline-header {
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground, #333);
}
.timeline-title-text {
font-weight: 700;
}
.current-time {
font-size: 0.875rem;
color: var(--foreground, #333);
}
.time-code {
font-family: monospace;
background-color: #f3f4f6;
padding: 0 0.5rem;
border-radius: 0.25rem;
}
.duration-time {
font-size: 0.875rem;
color: var(--foreground, #333);
}
.timeline-scroll-container {
position: relative;
overflow: visible !important;
}
.timeline-container {
position: relative;
min-width: 100%;
background-color: #fafbfc;
height: 70px;
border-radius: 0.25rem;
overflow: visible !important;
}
.timeline-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: red;
z-index: 30;
pointer-events: none;
}
.timeline-marker-head {
position: absolute;
top: -11px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 20px;
background-color: red;
border-radius: 50%;
pointer-events: auto;
cursor: pointer;
z-index: 31;
display: flex;
align-items: center;
justify-content: center;
}
.timeline-marker-head-icon {
color: white;
font-size: 14px;
font-weight: bold;
line-height: 1;
}
.trim-line-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: rgba(0, 0, 0, 0.5);
z-index: 20;
}
.trim-handle {
position: absolute;
width: 10px;
height: 20px;
background-color: black;
cursor: ew-resize;
&.left {
right: 0;
top: 10px;
border-radius: 3px 0 0 3px;
}
&.right {
left: 0;
top: 10px;
border-radius: 0 3px 3px 0;
}
}
.timeline-thumbnail {
display: inline-block;
height: 70px;
border-right: 1px solid rgba(0, 0, 0, 0.03);
}
.split-point {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background-color: rgba(255, 0, 0, 0.5);
z-index: 15;
}
.clip-segment {
position: absolute;
height: 70px;
border-radius: 4px;
z-index: 10;
border: 2px solid rgba(0, 0, 0, 0.15);
cursor: pointer;
&:hover {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3);
border-color: rgba(0, 0, 0, 0.4);
background-color: rgba(240, 240, 240, 0.8) !important;
}
&.selected {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.7);
border-color: rgba(59, 130, 246, 0.9);
}
&.selected:hover {
background-color: rgba(240, 248, 255, 0.85) !important;
}
}
.clip-segment-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.4rem;
background-color: rgba(0, 0, 0, 0.4);
color: white;
opacity: 1;
transition: background-color 0.2s;
line-height: 1.3;
}
.clip-segment:hover .clip-segment-info {
background-color: rgba(0, 0, 0, 0.5);
}
.clip-segment.selected .clip-segment-info {
background-color: rgba(59, 130, 246, 0.5);
}
.clip-segment.selected:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.4);
}
.clip-segment-name {
font-weight: 700;
font-size: 12px;
}
.clip-segment-time {
font-size: 10px;
}
.clip-segment-duration {
font-size: 10px;
}
.clip-segment-handle {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
background-color: rgba(0, 0, 0, 0.2);
cursor: ew-resize;
}
.clip-segment-handle:hover {
background-color: rgba(0, 0, 0, 0.4);
}
.clip-segment-handle.left {
left: 0;
border-radius: 2px 0 0 2px;
}
.clip-segment-handle.right {
right: 0;
border-radius: 0 2px 2px 0;
}
/* Enhanced handles for touch devices */
@media (pointer: coarse) {
.clip-segment-handle {
width: 14px; /* Wider target for touch devices */
background-color: rgba(0, 0, 0, 0.4); /* Darker by default for better visibility */
}
.clip-segment-handle:after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 20px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 1px;
}
.clip-segment-handle.left:after {
box-shadow: -2px 0 0 rgba(0, 0, 0, 0.5);
}
.clip-segment-handle.right:after {
box-shadow: 2px 0 0 rgba(0, 0, 0, 0.5);
}
/* Active state for touch feedback */
.clip-segment-handle:active {
background-color: rgba(0, 0, 0, 0.6);
}
}
.segment-tooltip,
.empty-space-tooltip {
position: absolute;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
padding: 0.5rem;
z-index: 1000;
min-width: 150px;
text-align: center;
pointer-events: auto;
top: -90px !important;
}
.segment-tooltip:after,
.empty-space-tooltip:after {
content: '';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid white;
}
.tooltip-time {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.5rem;
color: #333;
}
.tooltip-actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.tooltip-action-btn {
background-color: #f3f4f6;
border: none;
border-radius: 0.25rem;
padding: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #4b5563;
min-width: 20px !important;
}
.tooltip-action-btn:hover {
background-color: #e5e7eb;
color: #111827;
}
.tooltip-action-btn.delete {
color: #ef4444;
}
.tooltip-action-btn.delete:hover {
background-color: #fee2e2;
}
.tooltip-action-btn.new-segment {
padding: 0.375rem 0.5rem;
}
.tooltip-action-btn.new-segment .tooltip-btn-text {
margin-left: 0.25rem;
font-size: 0.75rem;
}
.tooltip-action-btn svg {
width: 1rem;
height: 1rem;
}
.timeline-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.75rem;
}
.time-navigation {
display: none;
align-items: center;
gap: 0.5rem;
}
.time-nav-label {
font-size: 0.875rem;
font-weight: 500;
}
.time-input {
border: 1px solid #d1d5db;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
width: 8rem;
font-size: 0.875rem;
}
.time-button-group {
display: flex;
}
.time-button {
background-color: #e5e7eb;
color: black;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
border: none;
cursor: pointer;
margin-right: 0.50rem;
}
.time-button:hover {
background-color: #d1d5db;
}
.time-button:first-child {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.time-button:last-child {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
.controls-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.zoom-dropdown-container {
position: relative;
z-index: 100;
display: none;
}
.zoom-button {
background-color: #374151;
color: white;
border: none;
border-radius: 0.25rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
display: flex;
align-items: center;
cursor: pointer;
}
.zoom-button:hover {
background-color: #1f2937;
}
.zoom-button svg {
margin-left: 0.25rem;
}
.zoom-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
width: 9rem;
background-color: #374151;
color: white;
border-radius: 0.25rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 50;
max-height: 300px;
overflow-y: auto;
}
.zoom-option {
padding: 0.25rem 0.75rem;
cursor: pointer;
}
.zoom-option:hover {
background-color: #4b5563;
}
.zoom-option.selected {
background-color: #6b7280;
display: flex;
align-items: center;
}
.zoom-option svg {
margin-right: 0.25rem;
}
/* Save buttons container */
.save-buttons-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
flex-wrap: nowrap;
}
/* General styles for all save buttons */
.save-button,
.save-copy-button,
.save-segments-button {
color: #ffffff;
background: #0066cc;
border-radius: 0.25rem;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
border: none;
white-space: nowrap;
transition: background-color 0.2s;
min-width: fit-content;
}
/* Shared hover effect */
.save-button:hover,
.save-copy-button:hover,
.save-segments-button:hover {
background-color: rgba(9, 59, 109, 0.9);
}
/* Media query for smaller screens */
@media (max-width: 576px) {
.save-buttons-row {
width: 100%;
justify-content: space-between;
gap: 0.5rem;
}
.save-button,
.save-copy-button,
.save-segments-button {
flex: 1;
font-size: 0.7rem;
padding: 0.25rem 0.35rem;
}
}
/* Very small screens - adjust save buttons */
@media (max-width: 480px) {
.save-button,
.save-copy-button,
.save-segments-button {
font-size: 0.675rem;
padding: 0.25rem;
}
/* Remove margins for controls-right buttons */
.controls-right {
margin: 0;
}
.controls-right button {
margin: 0;
}
}
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
}
@@ -0,0 +1,259 @@
.two-row-tooltip {
display: flex;
flex-direction: column;
background-color: white;
padding: 6px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative;
z-index: 3000; /* Highest z-index to ensure it's above all other elements */
}
/* Hide ±100ms buttons for more compact tooltip */
.tooltip-time-btn[data-tooltip="Decrease by 100ms"],
.tooltip-time-btn[data-tooltip="Increase by 100ms"] {
display: none !important;
}
.tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 3px;
}
.tooltip-row:first-child {
margin-bottom: 6px;
}
.tooltip-time-btn {
background-color: #f0f0f0 !important;
border: none !important;
border-radius: 4px !important;
padding: 4px 8px !important;
font-size: 0.75rem !important;
font-weight: 500 !important;
color: #333 !important;
cursor: pointer !important;
transition: background-color 0.2s !important;
min-width: 20px !important;
}
.tooltip-time-btn:hover {
background-color: #e0e0e0 !important;
}
.tooltip-time-display {
font-family: monospace !important;
font-size: 0.875rem !important;
font-weight: 600 !important;
color: #333 !important;
padding: 4px 6px !important;
background-color: #f7f7f7 !important;
border-radius: 4px !important;
min-width: 100px !important;
text-align: center !important;
overflow: hidden !important;
}
.tooltip-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 3px;
position: relative;
z-index: 2500; /* Higher z-index to ensure buttons appear above other elements */
}
.tooltip-action-btn {
background-color: #f3f4f6;
border: none;
border-radius: 4px;
padding: 5px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #4b5563;
width: 26px;
height: 26px;
min-width: 20px !important;
position: relative; /* Add relative positioning for tooltips */
}
/* Custom tooltip styles for second row action buttons - positioned below */
.tooltip-action-btn[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
height: 30px;
top: 35px; /* Position below the button with increased space */
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center horizontally */
margin-left: 0; /* Reset margin */
background-color: rgba(0, 0, 0, 0.85);
color: white;
text-align: left;
padding: 6px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 2500; /* High z-index */
pointer-events: none;
}
/* Triangle arrow pointing up to the button */
.tooltip-action-btn[data-tooltip]:after {
content: '';
position: absolute;
top: 35px; /* Match the before element */
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center horizontally */
border-width: 4px;
border-style: solid;
/* Arrow pointing down from button to tooltip */
border-color: rgba(0, 0, 0, 0.85) transparent transparent transparent;
margin-left: 0; /* Reset margin */
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 2500; /* High z-index */
pointer-events: none;
}
/* Show tooltips on hover - but only on devices with hover capability (desktops) */
@media (hover: hover) and (pointer: fine) {
.tooltip-action-btn[data-tooltip]:hover:before,
.tooltip-action-btn[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Keep the two-row-tooltip visible but hide button attribute tooltips on touch devices */
@media (pointer: coarse) {
.tooltip-action-btn[data-tooltip]:before,
.tooltip-action-btn[data-tooltip]:after {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
content: none !important;
}
}
.tooltip-action-btn:hover {
background-color: #e5e7eb;
color: #111827;
}
.tooltip-action-btn.delete {
color: #ef4444;
}
.tooltip-action-btn.delete:hover {
background-color: #fee2e2;
}
.tooltip-action-btn.play {
color: #10b981;
}
.tooltip-action-btn.play:hover {
background-color: #d1fae5;
}
.tooltip-action-btn.pause {
color: #3b82f6;
}
.tooltip-action-btn.pause:hover {
background-color: #dbeafe;
}
.tooltip-action-btn.play-from-start {
color: #4f46e5;
}
.tooltip-action-btn.play-from-start:hover {
background-color: #e0e7ff;
}
.tooltip-action-btn svg {
width: 16px;
height: 16px;
}
/* Adjust the new segment button style */
.tooltip-action-btn.new-segment {
width: auto;
height: auto;
padding: 6px 10px;
display: flex;
flex-direction: row;
color: #10b981;
}
.tooltip-action-btn.new-segment:hover {
background-color: #d1fae5;
}
.tooltip-action-btn.new-segment .tooltip-btn-text {
margin-left: 6px;
font-size: 0.75rem;
white-space: nowrap;
}
/* Additional mobile optimizations */
@media (max-width: 768px) {
.two-row-tooltip {
padding: 4px;
}
.tooltip-row:first-child {
margin-bottom: 4px;
}
.tooltip-time-btn {
min-width: 20px !important;
font-size: 0.7rem !important;
padding: 3px 6px !important;
}
.tooltip-time-display {
font-size: 0.8rem !important;
padding: 3px 4px !important;
min-width: 90px !important;
}
.tooltip-action-btn {
width: 24px;
height: 24px;
padding: 4px;
}
.tooltip-action-btn.new-segment {
padding: 4px 8px;
}
.tooltip-action-btn svg {
width: 14px;
height: 14px;
}
/* Adjust tooltip position for small screens - maintain the same position but adjust size */
.tooltip-action-btn[data-tooltip]:before {
min-width: 100px;
font-size: 11px;
padding: 4px 8px;
height: 24px;
top: 33px; /* Maintain the same relative distance on mobile */
}
.tooltip-action-btn[data-tooltip]:after {
top: 33px; /* Match the tooltip position */
}
}
@@ -0,0 +1,219 @@
#video-editor-trim-root {
/* Tooltip styles - only on desktop where hover is available */
@media (hover: hover) and (pointer: fine) {
[data-tooltip] {
position: relative;
}
[data-tooltip]:before {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 5px;
background-color: rgba(0, 0, 0, 0.8);
color: white;
text-align: center;
padding: 5px 10px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 1000;
pointer-events: none;
}
[data-tooltip]:after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
pointer-events: none;
}
[data-tooltip]:hover:before,
[data-tooltip]:hover:after {
opacity: 1;
visibility: visible;
}
}
/* Hide button tooltips on touch devices */
@media (pointer: coarse) {
[data-tooltip]:before,
[data-tooltip]:after {
display: none !important;
content: none !important;
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
}
.video-player-container {
position: relative;
background-color: black;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1rem;
aspect-ratio: 16/9;
}
.video-player-container video {
width: 100%;
height: 100%;
cursor: pointer;
}
.play-pause-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
&.play-icon::before {
width: 0;
height: 0;
border-top: 15px solid transparent;
border-bottom: 15px solid transparent;
border-left: 25px solid white;
margin-left: 3px;
}
&.pause-icon::before {
width: 20px;
height: 25px;
border-left: 6px solid white;
border-right: 6px solid white;
}
}
.video-player-container:hover .play-pause-indicator {
opacity: 1;
}
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.75rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
opacity: 0;
transition: opacity 0.3s;
}
.video-player-container:hover .video-controls {
opacity: 1;
}
.video-current-time {
color: white;
font-size: 0.875rem;
}
.video-duration {
color: white;
font-size: 0.875rem;
}
.video-time-display {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
color: white;
font-size: 0.875rem;
}
.video-progress {
width: 100%;
height: 4px;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 2px;
position: relative;
cursor: pointer;
margin-bottom: 0.75rem;
&:hover {
height: 6px;
.video-scrubber {
transform: translate(-50%, -50%) scale(1.2);
}
}
}
.video-progress-fill {
height: 100%;
background-color: #ef4444;
border-radius: 2px;
position: absolute;
top: 0;
left: 0;
}
.video-scrubber {
width: 12px;
height: 12px;
background-color: #ef4444;
border-radius: 50%;
position: absolute;
top: 50%;
left: 0; /* This will be overridden by inline style */
/* Fix vertical centering by adjusting transform */
transform: translate(-50%, -50%);
transition: transform 0.2s;
/* Add a small border to make it more visible */
border: 1px solid rgba(255, 255, 255, 0.7);
}
.video-controls-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.mute-button,
.fullscreen-button {
min-width: auto;
color: white;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
svg {
width: 1.25rem;
height: 1.25rem;
}
}
}
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "client/src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
@@ -0,0 +1,14 @@
import { defineConfig } from "drizzle-kit";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL, ensure the database is provisioned");
}
export default defineConfig({
out: "./migrations",
schema: "./shared/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+52
View File
@@ -0,0 +1,52 @@
{
"name": "video-trim-js",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "tsx server/index.ts",
"dev:frontend": "vite",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"check": "tsc",
"db:push": "drizzle-kit push",
"build:django": "vite build --config vite.video-editor.config.ts --outDir ../../../static/video_editor"
},
"dependencies": {
"@neondatabase/serverless": "^0.10.4",
"@tanstack/react-query": "^5.74.4",
"clsx": "^2.1.1",
"drizzle-orm": "^0.39.1",
"drizzle-zod": "^0.7.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.3",
"zod": "^3.24.3"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@types/express": "4.17.21",
"@types/express-session": "^1.18.0",
"@types/node": "^20.17.30",
"@types/passport": "^1.0.16",
"@types/passport-local": "^1.0.38",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.30.6",
"esbuild": "^0.25.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^5.4.18"
},
"optionalDependencies": {
"bufferutil": "^4.0.8"
}
}
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
@@ -0,0 +1,70 @@
import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => {
const start = Date.now();
const path = req.path;
let capturedJsonResponse: Record<string, any> | undefined = undefined;
const originalResJson = res.json;
res.json = function (bodyJson, ...args) {
capturedJsonResponse = bodyJson;
return originalResJson.apply(res, [bodyJson, ...args]);
};
res.on("finish", () => {
const duration = Date.now() - start;
if (path.startsWith("/api")) {
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
if (capturedJsonResponse) {
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
}
if (logLine.length > 80) {
logLine = logLine.slice(0, 79) + "…";
}
log(logLine);
}
});
next();
});
(async () => {
const server = await registerRoutes(app);
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
const status = err.status || err.statusCode || 500;
const message = err.message || "Internal Server Error";
res.status(status).json({ message });
throw err;
});
// importantly only setup vite in development and after
// setting up all the other routes so the catch-all route
// doesn't interfere with the other routes
if (app.get("env") === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
// ALWAYS serve the app on port 5000
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = 5000;
server.listen({
port,
host: "0.0.0.0",
reusePort: true,
}, () => {
log(`serving on port ${port}`);
});
})();
@@ -0,0 +1,15 @@
import type { Express } from "express";
import { createServer, type Server } from "http";
import { storage } from "./storage";
export async function registerRoutes(app: Express): Promise<Server> {
// put application routes here
// prefix all routes with /api
// use storage to perform CRUD operations on the storage interface
// e.g. storage.insertUser(user) or storage.getUserByUsername(username)
const httpServer = createServer(app);
return httpServer;
}
@@ -0,0 +1,39 @@
import { users, type User, type InsertUser } from "@shared/schema";
// modify the interface with any CRUD methods
// you might need
export interface IStorage {
getUser(id: number): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
createUser(user: InsertUser): Promise<User>;
}
export class MemStorage implements IStorage {
private users: Map<number, User>;
currentId: number;
constructor() {
this.users = new Map();
this.currentId = 1;
}
async getUser(id: number): Promise<User | undefined> {
return this.users.get(id);
}
async getUserByUsername(username: string): Promise<User | undefined> {
return Array.from(this.users.values()).find(
(user) => user.username === username,
);
}
async createUser(insertUser: InsertUser): Promise<User> {
const id = this.currentId++;
const user: User = { ...insertUser, id };
this.users.set(id, user);
return user;
}
}
export const storage = new MemStorage();
@@ -0,0 +1,85 @@
import express, { type Express } from "express";
import fs from "fs";
import path from "path";
import { createServer as createViteServer, createLogger } from "vite";
import { type Server } from "http";
import viteConfig from "../vite.config";
import { nanoid } from "nanoid";
const viteLogger = createLogger();
export function log(message: string, source = "express") {
const formattedTime = new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
console.log(`${formattedTime} [${source}] ${message}`);
}
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
customLogger: {
...viteLogger,
error: (msg, options) => {
viteLogger.error(msg, options);
process.exit(1);
},
},
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"..",
"client",
"index.html",
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`,
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath = path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
app.use(express.static(distPath));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}
@@ -0,0 +1,17 @@
import { pgTable, text, serial, integer, boolean } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
username: text("username").notNull().unique(),
password: text("password").notNull(),
});
export const insertUserSchema = createInsertSchema(users).pick({
username: true,
password: true,
});
export type InsertUser = z.infer<typeof insertUserSchema>;
export type User = typeof users.$inferSelect;
@@ -0,0 +1,90 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./client/index.html", "./client/src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config;
+23
View File
@@ -0,0 +1,23 @@
{
"include": ["client/src/**/*", "shared/**/*", "server/**/*"],
"exclude": ["node_modules", "build", "dist", "**/*.test.ts"],
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo",
"noEmit": true,
"module": "ESNext",
"strict": true,
"lib": ["esnext", "dom", "dom.iterable"],
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"types": ["node", "vite/client"],
"paths": {
"@/*": ["./client/src/*"],
"@shared/*": ["./shared/*"]
}
}
}
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [
react(),
],
resolve: {
alias: {
"@": path.resolve(import.meta.dirname, "client", "src"),
"@shared": path.resolve(import.meta.dirname, "shared"),
},
},
root: path.resolve(import.meta.dirname, "client"),
build: {
outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true,
},
});
@@ -0,0 +1,53 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path, { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default defineConfig({
plugins: [
react(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "client", "src"),
"@shared": path.resolve(__dirname, "shared"),
},
},
root: path.resolve(__dirname, "client"),
define: {
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'production')
}
},
build: {
minify: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'client/src/main.tsx'),
name: 'VideoEditor',
formats: ['iife'],
fileName: () => 'video-editor.js',
},
rollupOptions: {
output: {
// Ensure CSS file has a predictable name
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'style.css') return 'video-editor.css';
return assetInfo.name;
},
// Add this to ensure the final bundle exposes React correctly
globals: {
'react': 'React',
'react-dom': 'ReactDOM'
}
},
},
// Output to Django's static directory
outDir: '../../../static/video_editor',
emptyOutDir: true,
external: ['react', 'react-dom']
},
});
File diff suppressed because it is too large Load Diff