|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<title>Sign Language Boundary Annotation Tool</title> |
|
<style> |
|
body { |
|
background: #0e1117; |
|
color: white; |
|
font-family: sans-serif; |
|
margin: 0; |
|
padding: 20px; |
|
} |
|
.container { |
|
max-width: 1000px; |
|
margin: 0 auto; |
|
padding: 20px; |
|
} |
|
.video-container { |
|
width: 100%; |
|
max-height: 60vh; |
|
margin: 20px 0; |
|
position: relative; |
|
} |
|
video { |
|
width: 100%; |
|
max-height: 60vh; |
|
object-fit: contain; |
|
} |
|
.timeline { |
|
width: 100%; |
|
height: 60px; |
|
background: #262730; |
|
position: relative; |
|
margin: 20px 0; |
|
cursor: default; |
|
} |
|
.marker { |
|
position: absolute; |
|
width: 4px; |
|
height: 100%; |
|
background: red; |
|
cursor: ew-resize; |
|
transition: background 0.2s; |
|
} |
|
.marker:hover { |
|
background: #ff6666; |
|
} |
|
.marker.selected { |
|
background: #00ff00; |
|
width: 6px; |
|
box-shadow: 0 0 5px rgba(0, 255, 0, 0.5); |
|
} |
|
button { |
|
background: #262730; |
|
color: white; |
|
border: 1px solid #555; |
|
padding: 10px 20px; |
|
cursor: pointer; |
|
margin: 5px; |
|
} |
|
button:hover { |
|
background: #363840; |
|
} |
|
#timestamps { |
|
color: white; |
|
margin: 10px 0; |
|
word-wrap: break-word; |
|
font-family: monospace; |
|
} |
|
.error { |
|
color: #ff4444; |
|
} |
|
.success { |
|
color: #44ff44; |
|
} |
|
#current-time { |
|
margin: 10px 0; |
|
font-family: monospace; |
|
} |
|
.adjustment-controls { |
|
display: none; |
|
margin: 20px 0; |
|
padding: 10px; |
|
background: #262730; |
|
border-radius: 5px; |
|
} |
|
.adjustment-controls.visible { |
|
display: block; |
|
} |
|
.control-group { |
|
margin: 10px 0; |
|
} |
|
.control-group button { |
|
margin: 0 5px; |
|
} |
|
.mode-buttons { |
|
margin-bottom: 10px; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="container"> |
|
<h1>Sign Language Boundary Annotation Tool</h1> |
|
<div id="error-message" class="error"></div> |
|
<div class="mode-buttons"> |
|
<button id="toggle-stage">Switch to Adjustment Mode</button> |
|
<button id="alignment-mode">Switch to Alignment Mode</button> |
|
</div> |
|
<div class="video-container"> |
|
<video id="video" controls> |
|
<source id="video-source" src="{% if video_id %}/video/{{ video_id }}.mp4{% endif %}" type="video/mp4"> |
|
</video> |
|
</div> |
|
<div class="timeline" id="timeline"></div> |
|
<div id="current-time">Current Time: 0.00</div> |
|
<div class="adjustment-controls" id="adjustment-controls"> |
|
<div class="control-group"> |
|
<button id="add-marker">Add New Marker</button> |
|
<button id="delete-marker">Delete Selected Marker</button> |
|
</div> |
|
<div class="control-group"> |
|
<button id="back-second">-1 second</button> |
|
<button id="forward-second">+1 second</button> |
|
</div> |
|
<div class="control-group"> |
|
<button id="back-frame">-1 frame</button> |
|
<button id="forward-frame">+1 frame</button> |
|
</div> |
|
</div> |
|
<button id="mark-button">Mark Boundary</button> |
|
<div id="timestamps"></div> |
|
<button id="save-button">Save Annotations</button> |
|
<div id="save-status"></div> |
|
</div> |
|
|
|
<script> |
|
|
|
const templateVideoId = "{{ video_id|default('') }}"; |
|
let currentVideo = ""; |
|
if (templateVideoId) { |
|
currentVideo = templateVideoId; |
|
} else { |
|
|
|
fetch('/videos') |
|
.then(response => response.json()) |
|
.then(videos => { |
|
if (videos.error) { |
|
document.getElementById('error-message').textContent = videos.error; |
|
return; |
|
} |
|
if (videos.length > 0) { |
|
currentVideo = videos[0].replace(/\.mp4$/, ""); |
|
document.getElementById('video-source').src = `/video/${videos[0]}`; |
|
document.getElementById('video').load(); |
|
} |
|
}) |
|
.catch(error => { |
|
document.getElementById('error-message').textContent = 'Error loading videos: ' + error; |
|
}); |
|
} |
|
|
|
let timestamps = []; |
|
let isAdjustmentMode = false; |
|
let isDragging = false; |
|
let currentMarker = null; |
|
let selectedMarker = null; |
|
let isAddingMarker = false; |
|
let keyRepeatInterval = null; |
|
const FRAME_DURATION = 1/30; |
|
const KEY_REPEAT_DELAY = 33; |
|
|
|
const video = document.getElementById('video'); |
|
const videoSource = document.getElementById('video-source'); |
|
const timeline = document.getElementById('timeline'); |
|
const markButton = document.getElementById('mark-button'); |
|
const saveButton = document.getElementById('save-button'); |
|
const errorMessage = document.getElementById('error-message'); |
|
const saveStatus = document.getElementById('save-status'); |
|
const toggleStage = document.getElementById('toggle-stage'); |
|
const currentTimeDisplay = document.getElementById('current-time'); |
|
const adjustmentControls = document.getElementById('adjustment-controls'); |
|
const deleteMarkerBtn = document.getElementById('delete-marker'); |
|
const backSecondBtn = document.getElementById('back-second'); |
|
const forwardSecondBtn = document.getElementById('forward-second'); |
|
const backFrameBtn = document.getElementById('back-frame'); |
|
const forwardFrameBtn = document.getElementById('forward-frame'); |
|
const addMarkerBtn = document.getElementById('add-marker'); |
|
|
|
function loadExistingAnnotations() { |
|
fetch(`/get_annotations/${currentVideo}`) |
|
.then(response => response.json()) |
|
.then(data => { |
|
if (data.timestamps) { |
|
timestamps = data.timestamps; |
|
updateTimestamps(); |
|
timeline.innerHTML = ''; |
|
timestamps.forEach(time => addMarker(time)); |
|
} |
|
}) |
|
.catch(error => console.error('Error loading annotations:', error)); |
|
} |
|
|
|
video.addEventListener('loadedmetadata', () => { |
|
if (currentVideo) loadExistingAnnotations(); |
|
}); |
|
|
|
toggleStage.onclick = () => { |
|
isAdjustmentMode = !isAdjustmentMode; |
|
toggleStage.textContent = isAdjustmentMode ? "Switch to Marking Mode" : "Switch to Adjustment Mode"; |
|
markButton.style.display = isAdjustmentMode ? 'none' : 'block'; |
|
adjustmentControls.style.display = isAdjustmentMode ? 'block' : 'none'; |
|
deselectMarker(); |
|
}; |
|
|
|
document.getElementById('alignment-mode').onclick = () => { |
|
window.location.href = `/alignment/${currentVideo}`; |
|
}; |
|
|
|
markButton.onclick = () => { |
|
const time = video.currentTime; |
|
timestamps.push(time); |
|
timestamps.sort((a, b) => a - b); |
|
updateTimestamps(); |
|
timeline.innerHTML = ''; |
|
timestamps.forEach(time => addMarker(time)); |
|
}; |
|
|
|
function addMarker(time) { |
|
const marker = document.createElement('div'); |
|
marker.className = 'marker'; |
|
marker.style.left = (time / video.duration * 100) + '%'; |
|
timeline.appendChild(marker); |
|
initializeMarkerDrag(marker); |
|
} |
|
|
|
function initializeMarkerDrag(marker) { |
|
marker.addEventListener('click', (e) => { |
|
e.stopPropagation(); |
|
selectMarker(marker); |
|
}); |
|
marker.addEventListener('mousedown', (e) => { |
|
isDragging = true; |
|
currentMarker = marker; |
|
document.addEventListener('mousemove', handleDrag); |
|
document.addEventListener('mouseup', () => { |
|
isDragging = false; |
|
document.removeEventListener('mousemove', handleDrag); |
|
}); |
|
}); |
|
} |
|
|
|
function handleDrag(e) { |
|
if (!isDragging || !currentMarker) return; |
|
const timelineRect = timeline.getBoundingClientRect(); |
|
let position = (e.clientX - timelineRect.left) / timelineRect.width; |
|
position = Math.max(0, Math.min(1, position)); |
|
const newTime = position * video.duration; |
|
currentMarker.style.left = `${position * 100}%`; |
|
const index = Array.from(timeline.children).indexOf(currentMarker); |
|
timestamps[index] = newTime; |
|
video.currentTime = newTime; |
|
updateTimestamps(); |
|
} |
|
|
|
function selectMarker(marker) { |
|
if (selectedMarker === marker) { |
|
deselectMarker(); |
|
return; |
|
} |
|
if (selectedMarker) { |
|
selectedMarker.classList.remove('selected'); |
|
} |
|
selectedMarker = marker; |
|
marker.classList.add('selected'); |
|
adjustmentControls.classList.add('visible'); |
|
const index = Array.from(timeline.children).indexOf(marker); |
|
const timestamp = timestamps[index]; |
|
video.currentTime = timestamp; |
|
} |
|
|
|
function deselectMarker() { |
|
if (selectedMarker) { |
|
selectedMarker.classList.remove('selected'); |
|
selectedMarker = null; |
|
adjustmentControls.classList.remove('visible'); |
|
} |
|
} |
|
|
|
function deleteSelectedMarker() { |
|
if (!selectedMarker) return; |
|
const index = Array.from(timeline.children).indexOf(selectedMarker); |
|
timestamps.splice(index, 1); |
|
selectedMarker.remove(); |
|
deselectMarker(); |
|
updateTimestamps(); |
|
} |
|
|
|
function adjustMarkerTime(adjustment) { |
|
if (!selectedMarker) return; |
|
const index = Array.from(timeline.children).indexOf(selectedMarker); |
|
let newTime = timestamps[index] + adjustment; |
|
newTime = Math.max(0, Math.min(video.duration, newTime)); |
|
timestamps[index] = newTime; |
|
selectedMarker.style.left = (newTime / video.duration * 100) + '%'; |
|
video.currentTime = newTime; |
|
updateTimestamps(); |
|
} |
|
|
|
function updateTimestamps() { |
|
const div = document.getElementById('timestamps'); |
|
div.textContent = timestamps.map(t => t.toFixed(2)).join(', '); |
|
} |
|
|
|
video.addEventListener('timeupdate', () => { |
|
currentTimeDisplay.textContent = `Current Time: ${video.currentTime.toFixed(2)}`; |
|
}); |
|
|
|
document.addEventListener('keydown', (e) => { |
|
if (selectedMarker) { |
|
switch (e.key) { |
|
case 'Delete': |
|
deleteSelectedMarker(); |
|
break; |
|
case 'ArrowLeft': |
|
if (!keyRepeatInterval) { |
|
adjustMarkerTime(-FRAME_DURATION); |
|
keyRepeatInterval = setInterval(() => { |
|
adjustMarkerTime(-FRAME_DURATION); |
|
}, KEY_REPEAT_DELAY); |
|
} |
|
break; |
|
case 'ArrowRight': |
|
if (!keyRepeatInterval) { |
|
adjustMarkerTime(FRAME_DURATION); |
|
keyRepeatInterval = setInterval(() => { |
|
adjustMarkerTime(FRAME_DURATION); |
|
}, KEY_REPEAT_DELAY); |
|
} |
|
break; |
|
} |
|
} |
|
}); |
|
|
|
document.addEventListener('keyup', (e) => { |
|
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && keyRepeatInterval) { |
|
clearInterval(keyRepeatInterval); |
|
keyRepeatInterval = null; |
|
} |
|
}); |
|
|
|
addMarkerBtn.onclick = (e) => { |
|
e.stopPropagation(); |
|
isAddingMarker = true; |
|
timeline.style.cursor = 'crosshair'; |
|
deselectMarker(); |
|
}; |
|
|
|
timeline.addEventListener('click', (e) => { |
|
if (isAddingMarker) { |
|
const timelineRect = timeline.getBoundingClientRect(); |
|
const position = (e.clientX - timelineRect.left) / timelineRect.width; |
|
const newTime = position * video.duration; |
|
timestamps.push(newTime); |
|
timestamps.sort((a, b) => a - b); |
|
timeline.innerHTML = ''; |
|
timestamps.forEach(t => addMarker(t)); |
|
const newMarker = timeline.children[timestamps.indexOf(newTime)]; |
|
selectMarker(newMarker); |
|
isAddingMarker = false; |
|
timeline.style.cursor = 'default'; |
|
video.currentTime = newTime; |
|
updateTimestamps(); |
|
} |
|
}); |
|
|
|
deleteMarkerBtn.onclick = (e) => { |
|
e.stopPropagation(); |
|
deleteSelectedMarker(); |
|
}; |
|
|
|
backSecondBtn.onclick = (e) => { |
|
e.stopPropagation(); |
|
adjustMarkerTime(-1); |
|
}; |
|
|
|
forwardSecondBtn.onclick = (e) => { |
|
e.stopPropagation(); |
|
adjustMarkerTime(1); |
|
}; |
|
|
|
backFrameBtn.onclick = (e) => { |
|
e.stopPropagation(); |
|
adjustMarkerTime(-FRAME_DURATION); |
|
}; |
|
|
|
forwardFrameBtn.onclick = (e) => { |
|
e.stopPropagation(); |
|
adjustMarkerTime(FRAME_DURATION); |
|
}; |
|
|
|
document.addEventListener('click', (e) => { |
|
if (!e.target.closest('.timeline') && !e.target.closest('.adjustment-controls')) { |
|
isAddingMarker = false; |
|
timeline.style.cursor = 'default'; |
|
} |
|
if (e.target.closest('.adjustment-controls') || |
|
e.target.classList.contains('marker') || |
|
e.target.closest('.control-group')) { |
|
return; |
|
} |
|
deselectMarker(); |
|
}); |
|
|
|
saveButton.onclick = () => { |
|
fetch('/save_annotations', { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
}, |
|
body: JSON.stringify({ |
|
video: currentVideo, |
|
timestamps: timestamps |
|
}) |
|
}) |
|
.then(response => response.json()) |
|
.then(data => { |
|
if (data.success) { |
|
saveStatus.className = 'success'; |
|
saveStatus.textContent = 'Annotations saved successfully!'; |
|
} else { |
|
saveStatus.className = 'error'; |
|
saveStatus.textContent = data.message; |
|
} |
|
}) |
|
.catch(error => { |
|
saveStatus.className = 'error'; |
|
saveStatus.textContent = 'Error saving annotations: ' + error; |
|
}); |
|
}; |
|
</script> |
|
</body> |
|
</html> |