Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Video Segment Annotator</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <style> | |
| .waveform { | |
| background: linear-gradient(to bottom, #1a202c, #2d3748); | |
| height: 80px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .waveform-line { | |
| position: absolute; | |
| bottom: 0; | |
| width: 2px; | |
| background-color: #2ED9FF; | |
| transition: height 0.1s ease; | |
| } | |
| .playhead { | |
| position: absolute; | |
| top: 0; | |
| bottom: 0; | |
| width: 2px; | |
| background-color: #fff; | |
| z-index: 10; | |
| } | |
| .selection-area { | |
| position: absolute; | |
| top: 0; | |
| bottom: 0; | |
| background-color: rgba(255, 213, 79, 0.6); | |
| z-index: 5; | |
| } | |
| .handle { | |
| position: absolute; | |
| top: 0; | |
| bottom: 0; | |
| width: 8px; | |
| background-color: #FFD54F; | |
| cursor: ew-resize; | |
| z-index: 15; | |
| } | |
| .marker { | |
| position: absolute; | |
| top: 0; | |
| width: 2px; | |
| height: 20px; | |
| background-color: #f56565; | |
| z-index: 8; | |
| } | |
| .time-ruler { | |
| height: 20px; | |
| background-color: #2d3748; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .time-tick { | |
| position: absolute; | |
| bottom: 0; | |
| width: 1px; | |
| background-color: #718096; | |
| } | |
| .time-label { | |
| position: absolute; | |
| bottom: 0; | |
| font-size: 10px; | |
| color: #a0aec0; | |
| transform: translateX(-50%); | |
| } | |
| .media-viewer { | |
| background-color: #1a202c; | |
| aspect-ratio: 16/9; | |
| } | |
| @media (max-width: 768px) { | |
| .toolbar { | |
| flex-wrap: wrap; | |
| } | |
| .toolbar button { | |
| margin-bottom: 4px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-900 text-gray-200 font-sans"> | |
| <div class="container mx-auto px-4 py-6"> | |
| <header class="mb-6"> | |
| <h1 class="text-2xl font-semibold text-blue-400">Video Segment Annotator</h1> | |
| <p class="text-gray-400">Professional timeline for precise video annotation</p> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-4 gap-4"> | |
| <!-- Main content area --> | |
| <div class="lg:col-span-3 space-y-4"> | |
| <!-- Media Viewer --> | |
| <div class="media-viewer rounded-lg overflow-hidden relative flex items-center justify-center"> | |
| <video id="videoElement" class="w-full h-full" controls> | |
| <source src="https://samplelib.com/lib/preview/mp4/sample-5s.mp4" type="video/mp4"> | |
| </video> | |
| <div class="absolute bottom-4 left-4 bg-black bg-opacity-70 px-2 py-1 rounded text-sm"> | |
| <span id="currentTime">00:00:00:00</span> / <span id="duration">00:00:05:00</span> | |
| </div> | |
| </div> | |
| <!-- Timeline Header --> | |
| <div class="time-ruler rounded-t-lg" id="timeRuler"> | |
| <!-- Time ticks will be generated by JavaScript --> | |
| </div> | |
| <!-- Audio Waveform --> | |
| <div class="waveform rounded-b-lg relative" id="waveformContainer"> | |
| <div class="playhead" id="playhead"></div> | |
| <div class="selection-area" id="selectionArea"></div> | |
| <div class="handle" id="inHandle"></div> | |
| <div class="handle" id="outHandle"></div> | |
| <!-- Waveform lines will be generated by JavaScript --> | |
| <!-- Markers will be added here --> | |
| </div> | |
| <!-- Zoom Controls --> | |
| <div class="flex items-center justify-between mt-2"> | |
| <div class="flex items-center space-x-2"> | |
| <button id="zoomIn" class="bg-gray-700 hover:bg-gray-600 p-2 rounded"> | |
| <i data-lucide="zoom-in" class="w-4 h-4"></i> | |
| </button> | |
| <button id="zoomOut" class="bg-gray-700 hover:bg-gray-600 p-2 rounded"> | |
| <i data-lucide="zoom-out" class="w-4 h-4"></i> | |
| </button> | |
| <input type="range" id="zoomSlider" min="1" max="100" value="50" class="w-32"> | |
| </div> | |
| <div class="text-sm text-gray-400"> | |
| Zoom: <span id="zoomLevel">100%</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Properties Panel --> | |
| <div class="lg:col-span-1 bg-gray-800 rounded-lg p-4 space-y-4"> | |
| <h2 class="text-lg font-semibold border-b border-gray-700 pb-2">Segment Properties</h2> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-400 mb-1">Start Time</label> | |
| <input type="text" id="startTime" value="00:00:01:00" class="w-full bg-gray-700 rounded px-3 py-2 text-sm"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-400 mb-1">End Time</label> | |
| <input type="text" id="endTime" value="00:00:03:00" class="w-full bg-gray-700 rounded px-3 py-2 text-sm"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-400 mb-1">Label</label> | |
| <select class="w-full bg-gray-700 rounded px-3 py-2 text-sm"> | |
| <option>Speech</option> | |
| <option>Music</option> | |
| <option>Sound Effect</option> | |
| <option>Silence</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-400 mb-1">Notes</label> | |
| <textarea class="w-full bg-gray-700 rounded px-3 py-2 text-sm h-20"></textarea> | |
| </div> | |
| <div class="space-y-2"> | |
| <h3 class="text-sm font-medium text-gray-400">Keyboard Shortcuts</h3> | |
| <div class="grid grid-cols-2 gap-2 text-xs"> | |
| <div class="bg-gray-700 p-2 rounded">Space: Play/Pause</div> | |
| <div class="bg-gray-700 p-2 rounded">J-K-L: Shuttle</div> | |
| <div class="bg-gray-700 p-2 rounded">I: Set In</div> | |
| <div class="bg-gray-700 p-2 rounded">O: Set Out</div> | |
| <div class="bg-gray-700 p-2 rounded">M: Add Marker</div> | |
| <div class="bg-gray-700 p-2 rounded">← →: Frame Step</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Toolbar --> | |
| <div class="mt-6 bg-gray-800 rounded-lg p-3 toolbar flex items-center space-x-2 overflow-x-auto"> | |
| <button class="bg-blue-600 hover:bg-blue-500 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="play" class="w-4 h-4 mr-1"></i> Play | |
| </button> | |
| <button class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="pause" class="w-4 h-4 mr-1"></i> Pause | |
| </button> | |
| <button class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="skip-back" class="w-4 h-4 mr-1"></i> Previous | |
| </button> | |
| <button class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="skip-forward" class="w-4 h-4 mr-1"></i> Next | |
| </button> | |
| <div class="h-6 w-px bg-gray-600 mx-1"></div> | |
| <button class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="type" class="w-4 h-4 mr-1"></i> Add Label | |
| </button> | |
| <button class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="scissors" class="w-4 h-4 mr-1"></i> Split | |
| </button> | |
| <button id="addMarkerBtn" class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="map-pin" class="w-4 h-4 mr-1"></i> Marker | |
| </button> | |
| <div class="h-6 w-px bg-gray-600 mx-1"></div> | |
| <button class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="save" class="w-4 h-4 mr-1"></i> Save | |
| </button> | |
| <button class="bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded flex items-center"> | |
| <i data-lucide="settings" class="w-4 h-4 mr-1"></i> Settings | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| // Initialize Lucide icons | |
| lucide.createIcons(); | |
| // Video element and timeline controls | |
| const video = document.getElementById('videoElement'); | |
| const currentTimeDisplay = document.getElementById('currentTime'); | |
| const durationDisplay = document.getElementById('duration'); | |
| const playhead = document.getElementById('playhead'); | |
| const waveformContainer = document.getElementById('waveformContainer'); | |
| const timeRuler = document.getElementById('timeRuler'); | |
| const zoomSlider = document.getElementById('zoomSlider'); | |
| const zoomLevel = document.getElementById('zoomLevel'); | |
| const zoomInBtn = document.getElementById('zoomIn'); | |
| const zoomOutBtn = document.getElementById('zoomOut'); | |
| const startTimeInput = document.getElementById('startTime'); | |
| const endTimeInput = document.getElementById('endTime'); | |
| const selectionArea = document.getElementById('selectionArea'); | |
| const inHandle = document.getElementById('inHandle'); | |
| const outHandle = document.getElementById('outHandle'); | |
| const addMarkerBtn = document.getElementById('addMarkerBtn'); | |
| // State variables | |
| let isDraggingPlayhead = false; | |
| let isDraggingInHandle = false; | |
| let isDraggingOutHandle = false; | |
| let zoomFactor = 1; | |
| let videoDuration = 5; // seconds (sample video is 5s) | |
| let markers = []; | |
| // Initialize the video | |
| video.addEventListener('loadedmetadata', function() { | |
| videoDuration = video.duration; | |
| durationDisplay.textContent = formatTime(videoDuration); | |
| // Generate waveform (simulated for this demo) | |
| generateWaveform(); | |
| // Generate time ruler | |
| generateTimeRuler(); | |
| // Set initial selection | |
| updateSelectionArea(1, 3); // 1s to 3s | |
| }); | |
| // Update current time display | |
| video.addEventListener('timeupdate', function() { | |
| currentTimeDisplay.textContent = formatTime(video.currentTime); | |
| // Update playhead position | |
| if (!isDraggingPlayhead) { | |
| const playheadPosition = (video.currentTime / videoDuration) * waveformContainer.offsetWidth; | |
| playhead.style.left = `${playheadPosition}px`; | |
| } | |
| }); | |
| // Format time as HH:MM:SS:FF (frames) | |
| function formatTime(seconds) { | |
| const date = new Date(seconds * 1000); | |
| const hh = date.getUTCHours().toString().padStart(2, '0'); | |
| const mm = date.getUTCMinutes().toString().padStart(2, '0'); | |
| const ss = date.getUTCSeconds().toString().padStart(2, '0'); | |
| const ff = Math.floor((seconds % 1) * 30).toString().padStart(2, '0'); // Assuming 30fps | |
| return `${hh}:${mm}:${ss}:${ff}`; | |
| } | |
| // Generate simulated waveform | |
| function generateWaveform() { | |
| const containerWidth = waveformContainer.offsetWidth; | |
| const lineCount = containerWidth / 2; // One line every 2px | |
| // Clear existing waveform | |
| waveformContainer.querySelectorAll('.waveform-line').forEach(el => el.remove()); | |
| // Generate random waveform lines | |
| for (let i = 0; i < lineCount; i++) { | |
| const line = document.createElement('div'); | |
| line.className = 'waveform-line'; | |
| // Position and random height | |
| const leftPos = i * 2; | |
| const height = Math.random() * 70 + 10; // Random height between 10-80px | |
| line.style.left = `${leftPos}px`; | |
| line.style.height = `${height}px`; | |
| waveformContainer.appendChild(line); | |
| } | |
| } | |
| // Generate time ruler ticks | |
| function generateTimeRuler() { | |
| const containerWidth = timeRuler.offsetWidth; | |
| const totalSeconds = videoDuration; | |
| const pixelsPerSecond = containerWidth / totalSeconds; | |
| // Clear existing ticks | |
| timeRuler.innerHTML = ''; | |
| // Add major ticks every second and minor ticks every 0.5 seconds | |
| for (let i = 0; i <= totalSeconds; i += 0.5) { | |
| const tick = document.createElement('div'); | |
| tick.className = 'time-tick'; | |
| const isMajorTick = i % 1 === 0; | |
| const leftPos = i * pixelsPerSecond; | |
| tick.style.left = `${leftPos}px`; | |
| tick.style.height = isMajorTick ? '10px' : '5px'; | |
| timeRuler.appendChild(tick); | |
| // Add labels for major ticks | |
| if (isMajorTick) { | |
| const label = document.createElement('div'); | |
| label.className = 'time-label'; | |
| label.textContent = i; | |
| label.style.left = `${leftPos}px`; | |
| timeRuler.appendChild(label); | |
| } | |
| } | |
| } | |
| // Update selection area between in and out points | |
| function updateSelectionArea(startTime, endTime) { | |
| const containerWidth = waveformContainer.offsetWidth; | |
| const startPos = (startTime / videoDuration) * containerWidth; | |
| const endPos = (endTime / videoDuration) * containerWidth; | |
| const width = endPos - startPos; | |
| selectionArea.style.left = `${startPos}px`; | |
| selectionArea.style.width = `${width}px`; | |
| inHandle.style.left = `${startPos}px`; | |
| outHandle.style.left = `${endPos}px`; | |
| startTimeInput.value = formatTime(startTime); | |
| endTimeInput.value = formatTime(endTime); | |
| } | |
| // Handle zoom controls | |
| zoomSlider.addEventListener('input', function() { | |
| zoomFactor = 1 + (this.value / 100) * 4; // 1x to 5x zoom | |
| zoomLevel.textContent = `${Math.round(zoomFactor * 100)}%`; | |
| // In a real app, this would adjust the timeline scale | |
| console.log(`Zoom changed to ${zoomFactor}x`); | |
| }); | |
| zoomInBtn.addEventListener('click', function() { | |
| zoomSlider.value = Math.min(parseInt(zoomSlider.value) + 10, 100); | |
| zoomSlider.dispatchEvent(new Event('input')); | |
| }); | |
| zoomOutBtn.addEventListener('click', function() { | |
| zoomSlider.value = Math.max(parseInt(zoomSlider.value) - 10, 0); | |
| zoomSlider.dispatchEvent(new Event('input')); | |
| }); | |
| // Playhead dragging | |
| playhead.addEventListener('mousedown', function(e) { | |
| isDraggingPlayhead = true; | |
| document.addEventListener('mousemove', movePlayhead); | |
| document.addEventListener('mouseup', stopDraggingPlayhead); | |
| e.preventDefault(); | |
| }); | |
| function movePlayhead(e) { | |
| if (!isDraggingPlayhead) return; | |
| const containerRect = waveformContainer.getBoundingClientRect(); | |
| let xPos = e.clientX - containerRect.left; | |
| xPos = Math.max(0, Math.min(xPos, containerRect.width)); | |
| playhead.style.left = `${xPos}px`; | |
| // Update video time | |
| const time = (xPos / containerRect.width) * videoDuration; | |
| video.currentTime = time; | |
| } | |
| function stopDraggingPlayhead() { | |
| isDraggingPlayhead = false; | |
| document.removeEventListener('mousemove', movePlayhead); | |
| document.removeEventListener('mouseup', stopDraggingPlayhead); | |
| } | |
| // In/Out handle dragging | |
| inHandle.addEventListener('mousedown', function(e) { | |
| isDraggingInHandle = true; | |
| document.addEventListener('mousemove', moveInHandle); | |
| document.addEventListener('mouseup', stopDraggingInHandle); | |
| e.preventDefault(); | |
| }); | |
| outHandle.addEventListener('mousedown', function(e) { | |
| isDraggingOutHandle = true; | |
| document.addEventListener('mousemove', moveOutHandle); | |
| document.addEventListener('mouseup', stopDraggingOutHandle); | |
| e.preventDefault(); | |
| }); | |
| function moveInHandle(e) { | |
| if (!isDraggingInHandle) return; | |
| const containerRect = waveformContainer.getBoundingClientRect(); | |
| let xPos = e.clientX - containerRect.left; | |
| const outPos = parseFloat(outHandle.style.left || '0'); | |
| xPos = Math.max(0, Math.min(xPos, outPos - 10)); // Don't go past out handle | |
| inHandle.style.left = `${xPos}px`; | |
| // Update selection area and seek video | |
| const startTime = (xPos / containerRect.width) * videoDuration; | |
| const endTime = (parseFloat(outHandle.style.left) / containerRect.width) * videoDuration; | |
| updateSelectionArea(startTime, endTime); | |
| video.currentTime = startTime; | |
| } | |
| function moveOutHandle(e) { | |
| if (!isDraggingOutHandle) return; | |
| const containerRect = waveformContainer.getBoundingClientRect(); | |
| let xPos = e.clientX - containerRect.left; | |
| const inPos = parseFloat(inHandle.style.left || '0'); | |
| xPos = Math.max(inPos + 10, Math.min(xPos, containerRect.width)); // Don't go before in handle | |
| outHandle.style.left = `${xPos}px`; | |
| // Update selection area and seek video | |
| const startTime = (parseFloat(inHandle.style.left) / containerRect.width) * videoDuration; | |
| const endTime = (xPos / containerRect.width) * videoDuration; | |
| updateSelectionArea(startTime, endTime); | |
| video.currentTime = endTime; | |
| } | |
| function stopDraggingInHandle() { | |
| isDraggingInHandle = false; | |
| document.removeEventListener('mousemove', moveInHandle); | |
| document.removeEventListener('mouseup', stopDraggingInHandle); | |
| } | |
| function stopDraggingOutHandle() { | |
| isDraggingOutHandle = false; | |
| document.removeEventListener('mousemove', moveOutHandle); | |
| document.removeEventListener('mouseup', stopDraggingOutHandle); | |
| } | |
| // Add marker at current time | |
| addMarkerBtn.addEventListener('click', function() { | |
| const markerTime = video.currentTime; | |
| const markerId = Date.now(); | |
| markers.push({ | |
| id: markerId, | |
| time: markerTime | |
| }); | |
| addMarkerToTimeline(markerId, markerTime); | |
| }); | |
| function addMarkerToTimeline(id, time) { | |
| const marker = document.createElement('div'); | |
| marker.className = 'marker'; | |
| marker.dataset.id = id; | |
| const containerWidth = waveformContainer.offsetWidth; | |
| const leftPos = (time / videoDuration) * containerWidth; | |
| marker.style.left = `${leftPos}px`; | |
| // Add tooltip | |
| marker.title = `Marker at ${formatTime(time)}`; | |
| // Double click to remove | |
| marker.addEventListener('dblclick', function() { | |
| marker.remove(); | |
| markers = markers.filter(m => m.id !== id); | |
| }); | |
| waveformContainer.appendChild(marker); | |
| } | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', function(e) { | |
| // Space - play/pause | |
| if (e.code === 'Space') { | |
| e.preventDefault(); | |
| if (video.paused) { | |
| video.play(); | |
| } else { | |
| video.pause(); | |
| } | |
| } | |
| // I - set in point | |
| if (e.code === 'KeyI') { | |
| e.preventDefault(); | |
| const inTime = video.currentTime; | |
| const outTime = parseFloat(endTimeInput.value.split(':')[3]) / 30 + | |
| parseFloat(endTimeInput.value.split(':')[2]) + | |
| parseFloat(endTimeInput.value.split(':')[1]) * 60 + | |
| parseFloat(endTimeInput.value.split(':')[0]) * 3600; | |
| updateSelectionArea(inTime, Math.max(inTime + 0.1, outTime)); | |
| } | |
| // O - set out point | |
| if (e.code === 'KeyO') { | |
| e.preventDefault(); | |
| const outTime = video.currentTime; | |
| const inTime = parseFloat(startTimeInput.value.split(':')[3]) / 30 + | |
| parseFloat(startTimeInput.value.split(':')[2]) + | |
| parseFloat(startTimeInput.value.split(':')[1]) * 60 + | |
| parseFloat(startTimeInput.value.split(':')[0]) * 3600; | |
| updateSelectionArea(Math.min(inTime, outTime - 0.1), outTime); | |
| } | |
| // M - add marker | |
| if (e.code === 'KeyM') { | |
| e.preventDefault(); | |
| const markerTime = video.currentTime; | |
| const markerId = Date.now(); | |
| markers.push({ | |
| id: markerId, | |
| time: markerTime | |
| }); | |
| addMarkerToTimeline(markerId, markerTime); | |
| } | |
| // Left/Right arrow - frame step | |
| if (e.code === 'ArrowLeft') { | |
| e.preventDefault(); | |
| video.currentTime = Math.max(0, video.currentTime - (1/30)); // Step back 1 frame (30fps) | |
| } | |
| if (e.code === 'ArrowRight') { | |
| e.preventDefault(); | |
| video.currentTime = Math.min(videoDuration, video.currentTime + (1/30)); // Step forward 1 frame | |
| } | |
| }); | |
| // Make the timeline container responsive | |
| window.addEventListener('resize', function() { | |
| generateWaveform(); | |
| generateTimeRuler(); | |
| // Update playhead and selection positions | |
| const currentTime = video.currentTime; | |
| const playheadPosition = (currentTime / videoDuration) * waveformContainer.offsetWidth; | |
| playhead.style.left = `${playheadPosition}px`; | |
| const inTime = parseFloat(startTimeInput.value.split(':')[3]) / 30 + | |
| parseFloat(startTimeInput.value.split(':')[2]) + | |
| parseFloat(startTimeInput.value.split(':')[1]) * 60 + | |
| parseFloat(startTimeInput.value.split(':')[0]) * 3600; | |
| const outTime = parseFloat(endTimeInput.value.split(':')[3]) / 30 + | |
| parseFloat(endTimeInput.value.split(':')[2]) + | |
| parseFloat(endTimeInput.value.split(':')[1]) * 60 + | |
| parseFloat(endTimeInput.value.split(':')[0]) * 3600; | |
| updateSelectionArea(inTime, outTime); | |
| // Reposition markers | |
| markers.forEach(marker => { | |
| const markerEl = waveformContainer.querySelector(`.marker[data-id="${marker.id}"]`); | |
| if (markerEl) { | |
| const leftPos = (marker.time / videoDuration) * waveformContainer.offsetWidth; | |
| markerEl.style.left = `${leftPos}px`; | |
| } | |
| }); | |
| }); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=yhzion/video-annotator" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |