|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<title>Sign Language Alignment Tool</title> |
|
<style> |
|
body { |
|
background: #0e1117; |
|
color: white; |
|
font-family: sans-serif; |
|
margin: 0; |
|
padding: 20px; |
|
} |
|
|
|
.container { |
|
display: flex; |
|
max-width: 1200px; |
|
margin: 0 auto; |
|
gap: 20px; |
|
} |
|
.left-panel { |
|
flex: 1; |
|
min-width: 0; |
|
position: relative; |
|
z-index: 10; |
|
} |
|
.right-panel { |
|
flex: 1; |
|
min-width: 0; |
|
} |
|
.video-container { |
|
width: 100%; |
|
background: #1a1a1a; |
|
margin-bottom: 20px; |
|
} |
|
video { |
|
width: 100%; |
|
max-height: 60vh; |
|
object-fit: contain; |
|
} |
|
.transcript-container { |
|
background: #262730; |
|
padding: 20px; |
|
border-radius: 5px; |
|
max-height: 60vh; |
|
overflow-y: auto; |
|
position: relative; |
|
z-index: 1; |
|
} |
|
.word-boundary { |
|
display: inline-block; |
|
width: 4px; |
|
height: 1em; |
|
background: transparent; |
|
margin: 0 1px; |
|
cursor: pointer; |
|
vertical-align: middle; |
|
} |
|
.word-boundary:hover { |
|
background: #666; |
|
} |
|
.word-boundary.start-marker { |
|
background: #4CAF50; |
|
} |
|
|
|
.word-boundary.start-marker.adjusting { |
|
background-color: transparent !important; |
|
border-left: 4px dashed #4CAF50 !important; |
|
} |
|
.word-boundary.end-marker { |
|
background: #f44336; |
|
} |
|
.word { |
|
display: inline-block; |
|
padding: 2px; |
|
} |
|
button { |
|
background: #262730; |
|
color: white; |
|
border: 1px solid #555; |
|
padding: 10px 20px; |
|
cursor: pointer; |
|
margin: 5px; |
|
border-radius: 4px; |
|
position: relative; |
|
z-index: 10; |
|
} |
|
button:hover { |
|
background: #363840; |
|
} |
|
.warning-button { |
|
background: #863232; |
|
color: white; |
|
border: 1px solid #555; |
|
padding: 10px 20px; |
|
cursor: pointer; |
|
margin: 5px; |
|
border-radius: 4px; |
|
position: relative; |
|
z-index: 10; |
|
} |
|
.warning-button:hover { |
|
background: #a13d3d; |
|
} |
|
.selected-text { |
|
background: #262730; |
|
padding: 15px; |
|
margin: 10px 0; |
|
border-radius: 5px; |
|
min-height: 50px; |
|
} |
|
.skipped-clip { |
|
background: #4a3434; |
|
color: #ffcccc; |
|
padding: 10px; |
|
border-radius: 4px; |
|
margin: 5px 0; |
|
font-style: italic; |
|
} |
|
.clip-info { |
|
font-family: monospace; |
|
margin: 10px 0; |
|
color: #aaa; |
|
} |
|
|
|
#progress-container-wrapper { |
|
width: 100%; |
|
background: #0e1117; |
|
padding: 20px; |
|
text-align: center; |
|
} |
|
#progress-container, #transcription-progress-container { |
|
width: 100%; |
|
background-color: #444; |
|
border-radius: 5px; |
|
margin: 10px 0; |
|
overflow: hidden; |
|
} |
|
#progress-bar { |
|
height: 20px; |
|
width: 0%; |
|
background-color: #4CAF50; |
|
} |
|
#transcription-progress-bar { |
|
height: 20px; |
|
width: 0%; |
|
background-color: #2196F3; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<div id="progress-container-wrapper"> |
|
<h1>Preparing Video Clips and Transcription...</h1> |
|
<div id="extraction-progress-section"> |
|
<h3>Video Extraction Progress</h3> |
|
<div id="progress-container"> |
|
<div id="progress-bar"></div> |
|
</div> |
|
<div id="progress-text">0% complete</div> |
|
</div> |
|
<div id="transcription-progress-section"> |
|
<h3>Transcription Progress</h3> |
|
<div id="transcription-progress-container"> |
|
<div id="transcription-progress-bar"></div> |
|
</div> |
|
<div id="transcription-progress-text">0% complete</div> |
|
</div> |
|
</div> |
|
|
|
|
|
<div id="alignment-ui" style="display: none;"> |
|
<div class="container"> |
|
<div class="left-panel"> |
|
<h1>Sign Language Alignment Tool</h1> |
|
<button onclick="window.location.href='/player/' + videoId"> |
|
Return to Annotation Mode |
|
</button> |
|
<div class="video-container"> |
|
<video id="current-clip" controls> |
|
<source id="clip-source" type="video/mp4"> |
|
</video> |
|
</div> |
|
<div class="clip-info"> |
|
<div>Current Clip: <span id="current-clip-number">1</span> of <span id="total-clips">0</span></div> |
|
<div>Clip Time Range: <span id="clip-range">0.00 - 0.00</span></div> |
|
</div> |
|
<div> |
|
<button id="prev-clip">Previous Clip</button> |
|
<button id="next-clip">Next Clip</button> |
|
<button id="play-clip">Play Current Clip</button> |
|
</div> |
|
<div> |
|
<h3>Selected Text</h3> |
|
<div class="selected-text" id="selected-text">No text selected</div> |
|
<div class="button-group"> |
|
<button id="confirm-selection">Confirm Text Selection</button> |
|
<button id="mark-no-text" class="warning-button">No English Text Matches This Signing</button> |
|
</div> |
|
</div> |
|
<button id="save-alignments">Save All Alignments</button> |
|
</div> |
|
<div class="right-panel"> |
|
<div class="transcript-container" id="transcript-container"> |
|
<h3>Full Transcript</h3> |
|
<div id="transcript-text"></div> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
|
|
const pathSegments = window.location.pathname.split('/').filter(seg => seg !== ''); |
|
const videoId = pathSegments[pathSegments.length - 1]; |
|
|
|
let currentClipIndex = 0; |
|
let clips = []; |
|
let wordTimings = []; |
|
let alignments = []; |
|
let startMarker = null; |
|
let endMarker = null; |
|
let skippedClips = new Set(); |
|
let minStartAllowed = 0; |
|
let adjustingStart = false; |
|
let originalStartMarker = null; |
|
|
|
function renderTranscript() { |
|
const container = document.getElementById('transcript-text'); |
|
container.innerHTML = ''; |
|
const initialBoundary = document.createElement('span'); |
|
initialBoundary.className = 'word-boundary'; |
|
initialBoundary.dataset.index = '0'; |
|
container.appendChild(initialBoundary); |
|
wordTimings.forEach((word, i) => { |
|
const wordSpan = document.createElement('span'); |
|
wordSpan.className = 'word'; |
|
wordSpan.textContent = word.punctuated_word; |
|
container.appendChild(wordSpan); |
|
const boundary = document.createElement('span'); |
|
boundary.className = 'word-boundary'; |
|
boundary.dataset.index = (i + 1).toString(); |
|
container.appendChild(boundary); |
|
}); |
|
document.querySelectorAll('.word-boundary').forEach(boundary => { |
|
boundary.onclick = () => handleBoundaryClick(boundary); |
|
}); |
|
updateMarkers(); |
|
} |
|
|
|
function updateMarkers() { |
|
document.querySelectorAll('.word-boundary').forEach(boundary => { |
|
const index = parseInt(boundary.dataset.index); |
|
boundary.classList.remove('start-marker', 'end-marker', 'adjusting'); |
|
if (index === startMarker) { |
|
boundary.classList.add('start-marker'); |
|
if (adjustingStart) { |
|
boundary.classList.add('adjusting'); |
|
} |
|
} |
|
if (index === endMarker) { |
|
boundary.classList.add('end-marker'); |
|
} |
|
}); |
|
} |
|
|
|
function handleBoundaryClick(boundary) { |
|
const clickedIndex = parseInt(boundary.dataset.index); |
|
if (skippedClips.has(currentClipIndex)) { |
|
skippedClips.delete(currentClipIndex); |
|
updateSelectedText(); |
|
} |
|
if (startMarker === null) { |
|
if (clickedIndex < minStartAllowed) { |
|
alert("Cannot set boundary earlier than the previous segment's boundary."); |
|
return; |
|
} |
|
startMarker = clickedIndex; |
|
endMarker = null; |
|
} else if (adjustingStart) { |
|
if (clickedIndex < minStartAllowed) { |
|
alert("You cannot move the marker earlier than the previous segment's boundary."); |
|
} else { |
|
startMarker = clickedIndex; |
|
endMarker = null; |
|
} |
|
adjustingStart = false; |
|
originalStartMarker = null; |
|
} else if (clickedIndex === startMarker) { |
|
adjustingStart = !adjustingStart; |
|
originalStartMarker = startMarker; |
|
} else if (endMarker === null && clickedIndex > startMarker) { |
|
endMarker = clickedIndex; |
|
updateSelectedText(); |
|
} else if (clickedIndex > startMarker) { |
|
endMarker = clickedIndex; |
|
updateSelectedText(); |
|
} else { |
|
alert("Boundary selection invalid. End marker must be after start marker."); |
|
} |
|
updateMarkers(); |
|
} |
|
|
|
function updateSelectedText() { |
|
const selectedText = document.getElementById('selected-text'); |
|
if (skippedClips.has(currentClipIndex)) { |
|
selectedText.textContent = 'This clip has been marked as having no matching English text'; |
|
selectedText.className = 'selected-text skipped-clip'; |
|
return; |
|
} |
|
selectedText.className = 'selected-text'; |
|
if (startMarker === null || endMarker === null) { |
|
selectedText.textContent = 'No text selected'; |
|
return; |
|
} |
|
const selectedWords = wordTimings.slice(startMarker, endMarker).map(w => w.punctuated_word); |
|
selectedText.textContent = selectedWords.join(' '); |
|
} |
|
|
|
function loadClip(index) { |
|
if (index < 0 || index >= clips.length) { |
|
console.error("Invalid clip index:", index); |
|
return; |
|
} |
|
currentClipIndex = index; |
|
document.getElementById('current-clip-number').textContent = (index + 1); |
|
document.getElementById('total-clips').textContent = clips.length; |
|
const clip = clips[index]; |
|
const video = document.getElementById('current-clip'); |
|
const clipSource = document.getElementById('clip-source'); |
|
clipSource.src = clip.path; |
|
video.load(); |
|
document.getElementById('clip-range').textContent = |
|
`${clip.start.toFixed(2)} - ${clip.end.toFixed(2)}`; |
|
if (index === 0) { |
|
minStartAllowed = 0; |
|
startMarker = null; |
|
endMarker = null; |
|
} else { |
|
let lastValidAlignment = null; |
|
for (let i = index - 1; i >= 0; i--) { |
|
if (alignments[i]) { |
|
lastValidAlignment = alignments[i]; |
|
break; |
|
} |
|
} |
|
if (lastValidAlignment) { |
|
minStartAllowed = lastValidAlignment.endIndex; |
|
startMarker = minStartAllowed; |
|
endMarker = null; |
|
} else { |
|
minStartAllowed = 0; |
|
startMarker = null; |
|
endMarker = null; |
|
} |
|
} |
|
adjustingStart = false; |
|
originalStartMarker = null; |
|
updateMarkers(); |
|
updateSelectedText(); |
|
} |
|
|
|
document.getElementById('mark-no-text').onclick = () => { |
|
markNoText(); |
|
}; |
|
|
|
function markNoText() { |
|
skippedClips.add(currentClipIndex); |
|
startMarker = null; |
|
endMarker = null; |
|
if (alignments[currentClipIndex]) { |
|
delete alignments[currentClipIndex]; |
|
} |
|
updateMarkers(); |
|
updateSelectedText(); |
|
if (currentClipIndex < clips.length - 1) { |
|
loadClip(currentClipIndex + 1); |
|
} |
|
} |
|
|
|
document.getElementById('confirm-selection').onclick = () => { |
|
if (startMarker === null || endMarker === null) { |
|
alert('Please select text by clicking boundaries before confirming'); |
|
return; |
|
} |
|
if (endMarker <= startMarker) { |
|
alert('End marker must be after start marker'); |
|
return; |
|
} |
|
const asrStart = wordTimings[startMarker].start_time; |
|
const asrEnd = wordTimings[endMarker - 1].end_time; |
|
const textRaw = wordTimings.slice(startMarker, endMarker).map(w => w.word).join(' '); |
|
const textPunctuated = wordTimings.slice(startMarker, endMarker).map(w => w.punctuated_word).join(' '); |
|
alignments[currentClipIndex] = { |
|
clip: clips[currentClipIndex], |
|
startIndex: startMarker, |
|
endIndex: endMarker, |
|
audio_start: asrStart, |
|
audio_end: asrEnd, |
|
text: textPunctuated, |
|
text_raw: textRaw |
|
}; |
|
if (currentClipIndex < clips.length - 1) { |
|
loadClip(currentClipIndex + 1); |
|
} else { |
|
alert('This was the last clip! Please save your alignments.'); |
|
} |
|
}; |
|
|
|
document.getElementById('prev-clip').onclick = () => { |
|
if (currentClipIndex > 0) { |
|
loadClip(currentClipIndex - 1); |
|
} |
|
}; |
|
|
|
document.getElementById('next-clip').onclick = () => { |
|
if (currentClipIndex < clips.length - 1) { |
|
loadClip(currentClipIndex + 1); |
|
} |
|
}; |
|
|
|
document.getElementById('play-clip').onclick = () => { |
|
const video = document.getElementById('current-clip'); |
|
video.currentTime = 0; |
|
video.play(); |
|
}; |
|
|
|
document.getElementById('save-alignments').onclick = () => { |
|
const filteredAlignments = alignments.filter((alignment, index) => alignment && !skippedClips.has(index)); |
|
fetch('/api/save_alignments', { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
}, |
|
body: JSON.stringify({ |
|
video_id: videoId, |
|
alignments: filteredAlignments |
|
}) |
|
}) |
|
.then(response => response.json()) |
|
.then(data => { |
|
if (data.success) { |
|
alert('Alignments saved successfully!'); |
|
} else { |
|
alert('Error: ' + data.message); |
|
} |
|
}) |
|
.catch(error => { |
|
alert('Error saving alignments: ' + error); |
|
}); |
|
}; |
|
|
|
function pollProgress() { |
|
Promise.all([ |
|
fetch(`/api/clip_progress/${videoId}`).then(r => r.json()), |
|
fetch(`/api/transcription_progress/${videoId}`).then(r => r.json()) |
|
]).then(results => { |
|
const clipProgress = results[0]; |
|
const transcriptionProgress = results[1]; |
|
|
|
document.getElementById('progress-bar').style.width = clipProgress.percent + '%'; |
|
document.getElementById('progress-text').textContent = |
|
`Video Extraction ${clipProgress.percent}% complete`; |
|
document.getElementById('transcription-progress-bar').style.width = transcriptionProgress.percent + '%'; |
|
document.getElementById('transcription-progress-text').textContent = |
|
`Transcription ${transcriptionProgress.percent}% complete`; |
|
|
|
if (clipProgress.percent >= 100 && transcriptionProgress.percent >= 100) { |
|
document.getElementById('progress-container-wrapper').style.display = 'none'; |
|
document.getElementById('alignment-ui').style.display = 'block'; |
|
loadAlignmentData(); |
|
} else { |
|
setTimeout(pollProgress, 2000); |
|
} |
|
}).catch(error => { |
|
console.error("Error polling progress", error); |
|
setTimeout(pollProgress, 2000); |
|
}); |
|
} |
|
|
|
function loadAlignmentData() { |
|
Promise.all([ |
|
fetch(`/api/word_timestamps/${videoId}`).then(r => r.json()), |
|
fetch(`/api/clips/${videoId}`).then(r => r.json()) |
|
]).then(([wordData, clipsData]) => { |
|
if (wordData.status === 'success') { |
|
wordTimings = wordData.words; |
|
renderTranscript(); |
|
} else { |
|
console.error("Error loading word timestamps:", wordData.message); |
|
} |
|
if (clipsData.status === 'success') { |
|
clips = clipsData.clips; |
|
document.getElementById('total-clips').textContent = clips.length; |
|
if (clips.length > 0) loadClip(0); |
|
} else { |
|
console.error("Error loading clips:", clipsData.message); |
|
} |
|
}); |
|
} |
|
|
|
window.onload = function() { |
|
|
|
fetch(`/api/extract_clips/${videoId}`) |
|
.then(response => response.json()) |
|
.then(data => { |
|
console.log("Extraction & Transcription triggered:", data); |
|
pollProgress(); |
|
}) |
|
.catch(error => { |
|
console.error("Error triggering extraction and transcription:", error); |
|
pollProgress(); |
|
}); |
|
}; |
|
</script> |
|
</body> |
|
</html> |