영상 측정 기능 추가

This commit is contained in:
2025-07-03 17:55:17 +09:00
parent 73b6439702
commit 4520c9445c
2 changed files with 279 additions and 17 deletions
+275 -17
View File
@@ -190,6 +190,111 @@
background-color: #d35400; background-color: #d35400;
} }
.tab-container {
display: flex;
gap: 8px;
margin-bottom: 18px;
}
.tab-btn {
flex: 1;
padding: 12px 0;
font-size: 17px;
background: #eaf2f8;
border: none;
border-radius: 6px 6px 0 0;
color: #2980b9;
font-weight: bold;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.tab-btn.active {
background: #fff;
color: #222;
border-bottom: 2px solid #2980b9;
}
.tab-content {
display: none;
animation: fadein 0.25s;
}
.tab-content.active {
display: block;
}
.video-section {
margin-bottom: 32px;
}
.video-controls {
margin-bottom: 12px;
}
.video-upload-btn {
box-sizing: border-box;
width: 100%;
background: #3498db;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1.08em;
padding: 12px 32px;
cursor: pointer;
transition: background 0.18s;
box-shadow: 0 1px 4px rgba(52,152,219,0.08);
display: inline-block;
font-weight: 500;
text-align: center;
user-select: none;
}
.video-upload-btn:hover, .video-upload-btn:focus {
background: #217dbb;
}
.video-preview {
display: none;
}
.video-preview.active {
display: block;
}
.video-preview-large {
width: 100%;
height: auto;
margin-bottom: 10px;
display: block;
}
.video-preview-wrapper {
display: flex;
justify-content: center;
margin-bottom: 8px;
}
.timeline-btns {
display: flex;
gap: 10px;
}
.timeline-btn {
flex: 1;
background: #3498db;
color: #fff;
border: none;
border-radius: 8px;
font-size: 1.08em;
padding: 12px 32px;
margin: 0;
cursor: pointer;
transition: background 0.18s;
box-shadow: 0 1px 4px rgba(52,152,219,0.08);
display: inline-block;
font-weight: 500;
text-align: center;
user-select: none;
}
.timeline-btn:hover, .timeline-btn:focus {
background: #217dbb;
}
.reset-frame-btn {
display: block;
width: 100%;
margin: 12px 0 0 0;
}
.frame-time-info {
margin-bottom: 10px;
color: #2980b9;
}
</style> </style>
<!-- Google Tag Manager --> <!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
@@ -208,6 +313,7 @@
page_location: window.location.href page_location: window.location.href
}); });
</script> </script>
<script src="mp4box.all.min.js"></script>
</head> </head>
<body> <body>
<!-- Google Tag Manager (noscript) --> <!-- Google Tag Manager (noscript) -->
@@ -222,8 +328,12 @@
<p>시간 입력 또는 원하는 속도 입력으로 양방향 계산이 가능합니다. 볼링 레인의 기본 거리는 60피트(18.288미터)입니다.</p> <p>시간 입력 또는 원하는 속도 입력으로 양방향 계산이 가능합니다. 볼링 레인의 기본 거리는 60피트(18.288미터)입니다.</p>
</div> </div>
<div class="stopwatch"> <div class="tab-container">
<h3>스톱워치</h3> <button class="tab-btn active" id="tab-stopwatch">스톱워치</button>
<button class="tab-btn" id="tab-video">영상</button>
</div>
<div id="tab-content-stopwatch" class="tab-content active">
<h3>스톱워치로 측정</h3>
<p>볼링공이 파울라인을 지날 때 '시작'을 누르고, 헤드핀에 도달할 때 '정지'를 누르세요.</p> <p>볼링공이 파울라인을 지날 때 '시작'을 누르고, 헤드핀에 도달할 때 '정지'를 누르세요.</p>
<div class="stopwatch-display" id="stopwatch-display">00:00.000</div> <div class="stopwatch-display" id="stopwatch-display">00:00.000</div>
<div class="stopwatch-container"> <div class="stopwatch-container">
@@ -237,6 +347,24 @@
</button> <!-- [GA] 초기화 버튼 클릭 추적 --> </button> <!-- [GA] 초기화 버튼 클릭 추적 -->
</div> </div>
</div> </div>
<!-- 영상 업로드/촬영 및 프레임 선택 UI -->
<div id="tab-content-video" class="video-section tab-content">
<h2>볼링 영상으로 측정</h2>
<div class="video-controls">
<label class="video-upload-btn">
영상 업로드/촬영
<input type="file" id="video-upload" accept="video/*" capture="environment" style="display:none;">
</label>
</div>
<div class="video-preview-wrapper">
<video id="video-preview" class="video-preview-large" controls></video>
</div>
<div class="timeline-btns">
<button id="set-start-btn" class="timeline-btn" type="button">시작 지점 선택</button>
<button id="set-end-btn" class="timeline-btn" type="button">종료 지점 선택</button>
</div>
<button id="reset-frame-btn" class="timeline-btn reset-frame-btn" type="button">구간 초기화</button>
</div>
<div class="calculator"> <div class="calculator">
<div class="input-group"> <div class="input-group">
@@ -490,19 +618,19 @@
// 스톱워치 관련 변수 // 스톱워치 관련 변수
let stopwatchInterval; let stopwatchInterval;
let startTime; let wStartTime;
let elapsedTime = 0; let wElapsedTime = 0;
let isRunning = false; let wIsRunning = false;
// 스톱워치 시작/정지 토글 함수 // 스톱워치 시작/정지 토글 함수
function toggleStopwatch() { function toggleStopwatch() {
const toggleBtn = document.getElementById('toggle-btn'); const toggleBtn = document.getElementById('toggle-btn');
if (!isRunning) { if (!wIsRunning) {
// 시작 // 시작
startTime = Date.now() - elapsedTime; wStartTime = Date.now() - wElapsedTime;
stopwatchInterval = setInterval(updateStopwatch, 10); stopwatchInterval = setInterval(updateStopwatch, 10);
isRunning = true; wIsRunning = true;
// 버튼 상태 변경 // 버튼 상태 변경
toggleBtn.textContent = '정지'; toggleBtn.textContent = '정지';
@@ -510,8 +638,8 @@
} else { } else {
// 정지 // 정지
clearInterval(stopwatchInterval); clearInterval(stopwatchInterval);
elapsedTime = Date.now() - startTime; wElapsedTime = Date.now() - wStartTime;
isRunning = false; wIsRunning = false;
// 버튼 상태 변경 // 버튼 상태 변경
toggleBtn.textContent = '시작'; toggleBtn.textContent = '시작';
@@ -521,7 +649,7 @@
updateStopwatchDisplay(); // 스톱워치 표시 갱신 updateStopwatchDisplay(); // 스톱워치 표시 갱신
// 스톱워치와 동일한 값을 입력 필드에 설정 // 스톱워치와 동일한 값을 입력 필드에 설정
const exactSeconds = Math.floor((elapsedTime % 60000) / 1000) + ((elapsedTime % 1000) / 1000); const exactSeconds = Math.floor((wElapsedTime % 60000) / 1000) + ((wElapsedTime % 1000) / 1000);
document.getElementById('time').value = exactSeconds.toFixed(3); document.getElementById('time').value = exactSeconds.toFixed(3);
calculateSpeed(); calculateSpeed();
@@ -531,8 +659,8 @@
// 스톱워치 초기화 함수 // 스톱워치 초기화 함수
function resetStopwatch() { function resetStopwatch() {
clearInterval(stopwatchInterval); clearInterval(stopwatchInterval);
elapsedTime = 0; wElapsedTime = 0;
isRunning = false; wIsRunning = false;
updateStopwatchDisplay(); updateStopwatchDisplay();
// 버튼 상태 변경 // 버튼 상태 변경
@@ -543,15 +671,15 @@
// 스톱워치 표시 업데이트 함수 // 스톱워치 표시 업데이트 함수
function updateStopwatch() { function updateStopwatch() {
elapsedTime = Date.now() - startTime; wElapsedTime = Date.now() - wStartTime;
updateStopwatchDisplay(); updateStopwatchDisplay();
} }
// 스톱워치 시간 표시 형식 변환 함수 // 스톱워치 시간 표시 형식 변환 함수
function updateStopwatchDisplay() { function updateStopwatchDisplay() {
const minutes = Math.floor(elapsedTime / 60000); const minutes = Math.floor(wElapsedTime / 60000);
const seconds = Math.floor((elapsedTime % 60000) / 1000); const seconds = Math.floor((wElapsedTime % 60000) / 1000);
const milliseconds = elapsedTime % 1000; const milliseconds = wElapsedTime % 1000;
const display = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`; const display = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
document.getElementById('stopwatch-display').textContent = display; document.getElementById('stopwatch-display').textContent = display;
@@ -581,6 +709,136 @@
resetStopwatch(); resetStopwatch();
}); });
}); });
// --- 탭 전환 기능 개선 ---
const tabStopwatch = document.getElementById('tab-stopwatch');
const tabVideo = document.getElementById('tab-video');
const tabContentStopwatch = document.getElementById('tab-content-stopwatch');
const tabContentVideo = document.getElementById('tab-content-video');
function activateTab(tab) {
[tabStopwatch, tabVideo].forEach(btn => btn.classList.remove('active'));
[tabContentStopwatch, tabContentVideo].forEach(c => c.classList.remove('active'));
if(tab === 'stopwatch') {
tabStopwatch.classList.add('active');
tabContentStopwatch.classList.add('active');
} else {
tabVideo.classList.add('active');
tabContentVideo.classList.add('active');
}
}
tabStopwatch.onclick = () => activateTab('stopwatch');
tabVideo.onclick = () => activateTab('video');
// 최초 로드 시
activateTab('stopwatch');
// --- 영상 업로드/촬영 & 구간(프레임) 선택 기능 (시커바 기반) ---
const videoUpload = document.getElementById('video-upload');
const videoPreview = document.getElementById('video-preview');
const fileNameLabel = document.getElementById('file-name-label');
const setStartBtn = document.getElementById('set-start-btn');
const setEndBtn = document.getElementById('set-end-btn');
const resetFrameBtn = document.getElementById('reset-frame-btn');
let mediaRecorder, stream = null;
let videoFile = null;
let fps = 30;
let duration = 0;
// mp4box.js를 이용한 FPS 추출 함수 (Promise 반환)
function getFPSfromMP4(file) {
return new Promise((resolve) => {
if (!file || !file.type.includes('mp4')) {
resolve(30);
return;
}
const fileReader = new FileReader();
fileReader.onload = function(e) {
const arrayBuffer = e.target.result;
// mp4box.js는 appendBuffer의 인자로 fileStart 속성이 반드시 필요함
const mp4boxFile = MP4Box.createFile();
mp4boxFile.onReady = function(info) {
let fps = 30;
try {
const videoTrack = info.tracks.find(t => t.video);
if (videoTrack && videoTrack.video && videoTrack.video.frame_rate) {
fps = videoTrack.video.frame_rate;
}
} catch (e) { fps = 30; }
resolve(fps || 30);
};
arrayBuffer.fileStart = 0;
mp4boxFile.appendBuffer(arrayBuffer, 0);
mp4boxFile.flush();
};
fileReader.readAsArrayBuffer(file);
});
}
// startTime, endTime은 오직 여기서만 선언
let startTime = null;
let endTime = null;
const DIST_M = 18.288;
// 파일 업로드/촬영
videoUpload.onchange = function(e) {
if (!e.target.files || !e.target.files[0]) {
return;
}
videoFile = e.target.files[0];
videoPreview.src = URL.createObjectURL(videoFile);
videoPreview.classList.add('active');
videoPreview.onloadedmetadata = async function() {
duration = videoPreview.duration;
fps = await getFPSfromMP4(videoFile);
startTime = null;
endTime = null;
};
};
// 비디오 컨트롤(내장 시커바) 기반 시작/종료 지점 선택
setStartBtn.onclick = function() {
if (duration === 0) return;
startTime = videoPreview.currentTime;
setStartBtn.textContent = `시작 지점: ${startTime.toFixed(3)}`;
if (endTime !== null && endTime <= startTime) {
endTime = null;
setEndBtn.textContent = '종료 지점 선택';
}
updateSection();
};
setEndBtn.onclick = function() {
if (duration === 0) return;
if (startTime === null) {
alert('먼저 시작 지점을 선택하세요.');
return;
}
endTime = videoPreview.currentTime;
if (endTime <= startTime) {
alert('종료 지점은 시작 지점보다 뒤여야 합니다.');
endTime = null;
return;
}
setEndBtn.textContent = `종료 지점: ${endTime.toFixed(3)}`;
updateSection();
};
// 구간 초기화 버튼 클릭 시 버튼 텍스트도 초기화
resetFrameBtn.onclick = function() {
startTime = null;
endTime = null;
setStartBtn.textContent = '시작 지점 선택';
setEndBtn.textContent = '종료 지점 선택';
document.getElementById('distance-meters').value = DIST_M;
document.getElementById('distance-feet').value = perRound(DIST_M * 3.2808399, 2);
document.getElementById('time').value = '2.000';
calculateSpeed();
};
// 구간 시간 및 속도 계산
function updateSection() {
if (startTime !== null && endTime !== null) {
const dt = endTime - startTime;
document.getElementById('time').value = dt.toFixed(3);
calculateSpeed();
}
}
</script> </script>
</body> </body>
</html> </html>
File diff suppressed because one or more lines are too long