Files
make_video_site/public/editor.js
Claude 33addb304a fix(import): invalidate probe when URL input changes
Review P2: probe 성공 후 사용자가 URL 입력값을 다른 URL 로 바꿔도 "확인"
버튼이 여전히 활성화 상태였습니다. 그래서 A 로 probe → B 로 수정 → 확인을
누르면 B 가 probe 없이 바로 다운로드 시작됐습니다.

수정:

- `lastProbedUrl` 로 마지막으로 probe 통과한 URL 기록.
- ytUrl 의 input 이벤트에서 현재 값이 lastProbedUrl 과 다르면
  ytStartBtn 을 disable 로 되돌리고 ytProbeBtn 을 다시 활성화.
- ytStartBtn 클릭 핸들러에도 가드 추가: 클릭 시점에 URL ≠ lastProbedUrl
  이면 안내 메시지와 함께 차단 (race condition 대비).

이제 "확인 누르기 전 가져오기 못 누르게" 요구사항이 어느 순서로 입력이
들어와도 만족됩니다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:11:00 +09:00

397 lines
15 KiB
JavaScript

(function () {
var ctx = window.__EDITOR__ || { folder: '', video: null }
var folder = ctx.folder
var video = ctx.video
var dropZone = document.getElementById('dropZone')
var fileInput = document.getElementById('fileInput')
var ytUrl = document.getElementById('ytUrl')
var ytProbeBtn = document.getElementById('ytProbeBtn')
var ytStartBtn = document.getElementById('ytStartBtn')
var probeInfo = document.getElementById('probeInfo')
var dlProgress = document.getElementById('downloadProgress')
var uploadStatus = document.getElementById('uploadStatus')
var videoPanel = document.getElementById('videoPanel')
var editVideo = document.getElementById('editVideo')
var titleInput = document.getElementById('titleInput')
var startSec = document.getElementById('startSec')
var endSec = document.getElementById('endSec')
var saveBtn = document.getElementById('saveBtn')
// 드래그&드롭
;['dragenter', 'dragover'].forEach(function (evt) {
dropZone.addEventListener(evt, function (e) {
e.preventDefault(); e.stopPropagation()
dropZone.classList.add('dragOver')
})
})
;['dragleave', 'drop'].forEach(function (evt) {
dropZone.addEventListener(evt, function (e) {
e.preventDefault(); e.stopPropagation()
dropZone.classList.remove('dragOver')
})
})
dropZone.addEventListener('drop', function (e) {
var files = e.dataTransfer && e.dataTransfer.files
if (files && files.length > 0) uploadFile(files[0])
})
fileInput.addEventListener('change', function () {
if (fileInput.files && fileInput.files[0]) uploadFile(fileInput.files[0])
})
function uploadFile(file) {
var form = new FormData()
form.append('file', file)
form.append('title', titleInput.value || file.name)
uploadStatus.textContent = '업로드 중...'
var xhr = new XMLHttpRequest()
xhr.open('POST', '/op/folder/' + encodeURIComponent(folder) + '/video/upload')
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100)
uploadStatus.textContent = '업로드 ' + pct + '%'
}
})
xhr.onload = function () {
try {
var res = JSON.parse(xhr.responseText)
if (res.ok) {
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(res.videoId)
} else {
uploadStatus.textContent = '업로드 실패: ' + (res.message || '')
}
} catch (err) {
uploadStatus.textContent = '업로드 실패'
}
}
xhr.onerror = function () { uploadStatus.textContent = '업로드 실패 (네트워크)' }
xhr.send(form)
}
// YouTube probe
// "확인" 누르기 전 "가져오기" 재클릭 방지 + URL 변경 시 다시 probe 강제.
// 흐름: URL 입력 → 가져오기 → (성공 시) 확인 활성화 → 확인 클릭.
// URL 이 바뀌면 확인 비활성화로 돌아가고 가져오기 다시 활성화.
var lastProbedUrl = null
ytUrl.addEventListener('input', function () {
// 입력이 바뀌면 직전 probe 결과는 무효.
if (ytUrl.value.trim() !== lastProbedUrl) {
ytStartBtn.disabled = true
// probe 중이 아닐 때만 가져오기를 풀어준다 (probe 중간엔 잠금 유지).
ytProbeBtn.disabled = false
}
})
ytProbeBtn.addEventListener('click', function () {
var url = ytUrl.value.trim()
if (!url) return
probeInfo.textContent = '확인 중...'
ytProbeBtn.disabled = true
ytStartBtn.disabled = true
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url })
}).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '확인 실패'
ytProbeBtn.disabled = false
return
}
var p = j.probe
var parts = [
'제목: ' + p.title,
'길이: ' + formatDuration(p.durationSec)
]
if (p.filesizeApprox) parts.push('대략 ' + formatSize(p.filesizeApprox))
if (p.etaSec) parts.push('예상 다운로드: ' + formatDuration(p.etaSec))
probeInfo.textContent = parts.join(' · ')
if (p.warnOver5min) {
if (!window.confirm('가져오는 데 5분 이상 걸릴 수 있습니다. 진행할까요?\n(다른 페이지에서 작업해도 백그라운드로 계속 진행됩니다.)')) {
ytProbeBtn.disabled = false
return
}
}
if (!titleInput.value) titleInput.value = p.title
// 이 URL 에 한해 확인 활성화. URL 변경 감지용으로 마지막 probe URL 저장.
lastProbedUrl = url
ytStartBtn.disabled = false
}).catch(function (e) {
probeInfo.textContent = '확인 실패: ' + e.message
ytProbeBtn.disabled = false
})
})
ytStartBtn.addEventListener('click', function () {
var url = ytUrl.value.trim()
if (!url) return
// 가드: URL 이 바뀐 채로 확인이 눌리는 경우 (이벤트 race) 차단.
if (url !== lastProbedUrl) {
probeInfo.textContent = 'URL 이 바뀌었습니다. "가져오기" 를 다시 눌러 확인하세요.'
ytStartBtn.disabled = true
ytProbeBtn.disabled = false
return
}
// 중복 클릭 방지
ytStartBtn.disabled = true
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url, title: titleInput.value })
}).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '시작 실패'
// 시작 실패면 재시도 가능하게 둘 다 다시 풀어줌
ytProbeBtn.disabled = false
ytStartBtn.disabled = false
return
}
dlProgress.hidden = false
probeInfo.textContent = '백그라운드 다운로드 시작...'
pollJob(j.jobId, j.videoId)
}).catch(function (e) {
probeInfo.textContent = '시작 실패: ' + e.message
ytProbeBtn.disabled = false
ytStartBtn.disabled = false
})
})
function pollJob(jobId, videoId) {
// cache: 'no-store' 로 304 가 나지 않게 강제. 304 면 body 가 비어
// r.json() 이 reject → 폴링 중단되는 문제 방지.
fetch('/op/job/' + encodeURIComponent(jobId), { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '작업을 찾을 수 없음'
return
}
var job = j.job
dlProgress.value = job.progress
probeInfo.textContent = job.message
if (job.status === 'done') {
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
} else if (job.status === 'error') {
probeInfo.textContent = '실패: ' + (job.error || '')
} else {
setTimeout(function () { pollJob(jobId, videoId) }, 1500)
}
})
.catch(function (err) {
// 네트워크 일시 오류로 폴링이 영구 중단되지 않도록 짧게 백오프 후 재시도.
console.warn('[pollJob] fetch 실패, 재시도:', err)
setTimeout(function () { pollJob(jobId, videoId) }, 2000)
})
}
// ── 타임라인 트림 (lossless-cut 류 핸들 UI) ─────────────────────────
var trimBar = document.getElementById('trimBar')
var trimHandleStart = document.getElementById('trimHandleStart')
var trimHandleEnd = document.getElementById('trimHandleEnd')
var trimRangeFill = document.getElementById('trimRangeFill')
var trimPlayhead = document.getElementById('trimPlayhead')
var timeReadout = document.getElementById('timeReadout')
var trimDuration = document.getElementById('trimDuration')
var duration = 0
var trimStart = video && video.trim ? Number(video.trim.startSec) || 0 : 0
// trimEnd 가 null 이면 "끝까지" 의미
var trimEnd = video && video.trim && video.trim.endSec != null ? Number(video.trim.endSec) : null
var MIN_SELECTION = 0.05
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) }
function effectiveEnd() { return trimEnd != null ? trimEnd : duration }
function formatTime(sec) {
if (!isFinite(sec) || sec < 0) sec = 0
var m = Math.floor(sec / 60)
var s = sec - m * 60
var sStr = s.toFixed(1)
if (s < 10) sStr = '0' + sStr
return (m < 10 ? '0' + m : '' + m) + ':' + sStr
}
function renderTimeline() {
if (!duration || !trimBar) return
var end = effectiveEnd()
var startPct = (trimStart / duration) * 100
var endPct = (end / duration) * 100
trimRangeFill.style.left = startPct + '%'
trimRangeFill.style.width = Math.max(0, endPct - startPct) + '%'
trimHandleStart.style.left = startPct + '%'
trimHandleEnd.style.left = endPct + '%'
var cur = editVideo ? editVideo.currentTime : 0
trimPlayhead.style.left = clamp((cur / duration) * 100, 0, 100) + '%'
timeReadout.textContent = formatTime(cur) + ' / ' + formatTime(duration)
trimDuration.textContent = '선택: ' + (end - trimStart).toFixed(1) + '초'
// 숫자 입력 동기화
startSec.value = trimStart.toFixed(2)
endSec.value = trimEnd == null ? '' : trimEnd.toFixed(2)
}
function applyMetadata() {
var d = editVideo.duration
if (!d || !isFinite(d) || d <= 0) return
duration = d
// 잘못 저장돼있던 값 보정
trimStart = clamp(trimStart, 0, Math.max(0, duration - MIN_SELECTION))
if (trimEnd != null) trimEnd = clamp(trimEnd, trimStart + MIN_SELECTION, duration)
renderTimeline()
}
if (editVideo) {
// 스크립트가 loadedmetadata 이후에 실행되는 경우 (인라인 src) 도 커버.
if (editVideo.readyState >= 1) applyMetadata()
editVideo.addEventListener('loadedmetadata', applyMetadata)
editVideo.addEventListener('durationchange', applyMetadata)
editVideo.addEventListener('timeupdate', renderTimeline)
editVideo.addEventListener('seeked', renderTimeline)
}
function clientXtoTime(clientX) {
var rect = trimBar.getBoundingClientRect()
var ratio = clamp((clientX - rect.left) / rect.width, 0, 1)
return ratio * duration
}
function attachHandle(handle, which) {
handle.addEventListener('pointerdown', function (e) {
if (!duration) return
e.preventDefault()
handle.setPointerCapture(e.pointerId)
handle.classList.add('dragging')
function onMove(ev) {
var t = clientXtoTime(ev.clientX)
if (which === 'start') {
trimStart = clamp(t, 0, effectiveEnd() - MIN_SELECTION)
} else {
trimEnd = clamp(t, trimStart + MIN_SELECTION, duration)
}
renderTimeline()
}
function onUp() {
handle.classList.remove('dragging')
handle.removeEventListener('pointermove', onMove)
handle.removeEventListener('pointerup', onUp)
handle.removeEventListener('pointercancel', onUp)
}
handle.addEventListener('pointermove', onMove)
handle.addEventListener('pointerup', onUp)
handle.addEventListener('pointercancel', onUp)
})
}
if (trimBar) {
attachHandle(trimHandleStart, 'start')
attachHandle(trimHandleEnd, 'end')
// 타임라인 빈 공간 클릭 → 그 지점으로 시킹
trimBar.addEventListener('pointerdown', function (e) {
if (!duration || !editVideo) return
var tgt = e.target
if (tgt && (tgt === trimHandleStart || tgt === trimHandleEnd)) return
editVideo.currentTime = clientXtoTime(e.clientX)
})
}
// 시작/끝 마크 버튼
document.querySelectorAll('[data-mark]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!editVideo || !duration) return
var t = editVideo.currentTime
if (btn.getAttribute('data-mark') === 'start') {
trimStart = clamp(t, 0, effectiveEnd() - MIN_SELECTION)
} else {
trimEnd = clamp(t, trimStart + MIN_SELECTION, duration)
}
renderTimeline()
})
})
// 초기화 / 선택 재생
var playSelectionStopHandler = null
document.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.getAttribute('data-action')
if (action === 'reset') {
trimStart = 0; trimEnd = null
renderTimeline()
} else if (action === 'playSelection') {
if (!editVideo || !duration) return
editVideo.currentTime = trimStart
editVideo.play()
if (playSelectionStopHandler) editVideo.removeEventListener('timeupdate', playSelectionStopHandler)
playSelectionStopHandler = function () {
if (editVideo.currentTime >= effectiveEnd() - 0.02) {
editVideo.pause()
editVideo.removeEventListener('timeupdate', playSelectionStopHandler)
playSelectionStopHandler = null
}
}
editVideo.addEventListener('timeupdate', playSelectionStopHandler)
}
})
})
// 숫자 입력 → 상태로 반영
startSec.addEventListener('change', function () {
var v = Number(startSec.value || 0)
if (!isFinite(v) || v < 0) v = 0
trimStart = clamp(v, 0, duration ? effectiveEnd() - MIN_SELECTION : v)
renderTimeline()
})
endSec.addEventListener('change', function () {
if (endSec.value === '') {
trimEnd = null
} else {
var v = Number(endSec.value)
if (!isFinite(v) || v <= trimStart) { renderTimeline(); return }
trimEnd = duration ? clamp(v, trimStart + MIN_SELECTION, duration) : v
}
renderTimeline()
})
// 저장
saveBtn.addEventListener('click', function () {
if (!video) { alert('먼저 영상을 추가하세요.'); return }
var payload = {
id: video.id,
title: titleInput.value,
startSec: trimStart,
endSec: trimEnd
}
saveBtn.disabled = true
saveBtn.textContent = '저장 중...'
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) { return r.json() }).then(function (j) {
saveBtn.disabled = false
saveBtn.textContent = '저장'
if (j.ok) {
alert(j.note || '저장 완료')
location.href = '/op/folder/' + encodeURIComponent(folder)
} else {
alert(j.message || '저장 실패')
}
})
})
function formatDuration(sec) {
if (!sec || sec <= 0) return '0초'
var s = Math.round(sec)
var h = Math.floor(s / 3600); s = s % 3600
var m = Math.floor(s / 60); s = s % 60
if (h > 0) return h + '시간 ' + m + '분 ' + s + '초'
if (m > 0) return m + '분 ' + s + '초'
return s + '초'
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
})()