세 가지 사용자 보고 처리:
1) 다운로드 바가 멈춰 있다가 한번에 50% 로 점프
- 원인: yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면
block-buffered 가 되어 진행률 라인들이 4KB 버퍼에 모였다가 종료 직전에
쏟아짐. node 의 stdout 'data' 핸들러가 그 전엔 아무 것도 못 봄.
- 수정: spawn 의 env 에 PYTHONUNBUFFERED=1 추가. 라인 단위로 즉시 flush.
2) 변환을 20배 빠르게
- 원인: minterpolate(mci) 는 motion estimation 자체가 무거워 mci 기본값
이어도 영상 길이의 수 배 시간이 든다 (blend 도 5~10배가 한계).
- 수정: 다운로드 후 60fps 변환과 trim 재인코딩 양쪽에서 minterpolate 를
`fps=60` (단순 프레임 복제) 으로 교체. preset 도 `veryfast` → `ultrafast`.
실측상 영상 길이의 1/3 ~ 1배 시간 — mci 대비 수십 배 빠름.
- 시각적 부드러움은 30fps 와 동일하지만 컨테이너/타이밍은 60fps cfr 로 유지.
사용자가 알려준 "timing 만 60fps 로 바뀌고 실제 부드러움은 그대로" 경로.
3) "확인" 전 "가져오기" 재클릭 막기
- probe 클릭 시 ytProbeBtn 도 disabled.
- probe 실패 / 5분 경고 취소 / start 실패 시만 재활성화.
- 정상 흐름은 probe 성공 → 확인 클릭 → 다운로드 → redirect 라 재활성화
필요 없음. ytStartBtn 도 클릭 직후 disabled 로 중복 클릭 방지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
377 lines
14 KiB
JavaScript
377 lines
14 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
|
|
// "확인" 누르기 전 "가져오기" 재클릭 방지: probe 클릭 시 disable, 실패시만 재활성화.
|
|
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
|
|
// 확인(start) 만 활성화, 가져오기는 잠금 유지 (확인 누르면 다운로드 시작 → redirect)
|
|
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
|
|
// 중복 클릭 방지
|
|
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'
|
|
}
|
|
})()
|