세 가지 사용자 보고 처리:
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>
Review P1: 진행률 폴링이 304 응답을 받으면 r.json() 이 reject 되는데
.catch() 가 없어 setTimeout 도 안 걸리고 폴링이 영구 중단됐습니다.
이게 "60fps 변환 확인 중" 에서 바가 멈춰 보이던 진짜 원인이었어요.
세 곳을 다 고침:
1) src/routes/op.ts `/op/job/:id`
- `Cache-Control: no-store, no-cache, must-revalidate` + `Pragma: no-cache`
- 브라우저가 conditional GET 으로 304 를 받지 않게 한다.
2) public/editor.js fetch
- `{ cache: 'no-store' }` 옵션. 서버 헤더 + 클라 옵션 둘 다.
3) public/editor.js pollJob
- `.catch()` 추가. 일시적 네트워크/파싱 오류여도 2 초 백오프로
폴링을 재개한다. 변환이 오래 걸려도 바가 계속 갱신됨을 보장.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
다운로드 후 바가 한 번에 99% 로 튄 뒤 멈춰 있던 문제 두 가지를 같이 고침.
1) 진행률 구간 분리
- 다운로드: 0 ~ 50% (yt-dlp 의 0-100% 를 절반으로 매핑)
- 60fps 변환: 50 ~ 99%
- yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100% 를
반복하므로 단조 증가만 인정 (downloadPctRaw = max(...)).
2) ffmpeg minterpolate 진행률 실시간 보고
- editor.ts 에 `probeVideoDuration` 추가, `runFfmpegWithProgress`
도입해 ffmpeg `-progress pipe:1` 의 out_time_us/ms 를 파싱.
- `upscaleOriginalTo60Fps` 에 `onProgress(pct)` 콜백 추가.
- youtube.ts 가 콜백을 받아 50~99% 로 매핑하고 job.message 를
"60fps 변환 NN%" 로 갱신. persistJob 은 2초 throttle.
3) mci 옵션 단순화로 속도 개선
- 기존: `mci:mc_mode=aobmc:me_mode=bidir` (가장 느린 조합)
- 현재: `mci` 기본값 (obmc + bilat). 화질 약간 양보, 속도 수 배 개선.
- 여전히 mci 자체가 무거워 5분 30fps 영상 변환에 수십 분 단위 시간이
걸릴 수 있음 — 이제 그 동안 진행률은 실시간으로 움직임.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Review P2: 변환 성공 후 `unlink(input) → rename(tmp)` 순서였는데, unlink 가
성공하고 rename 이 실패하면 원본이 사라진 채 결과물도 없는 상태가 됩니다.
순서를 뒤집어 `rename(tmp → outPath)` 이 먼저 성공한 뒤에만 기존 원본을
지우도록 바꿨습니다. rename 실패 시에는 tmp 만 정리하고 inputName 을 반환해
"실패해도 원본은 그대로" 의도와 일치하게 됩니다.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Review P1: yt-dlp 가 fps 1순위로 정렬해 가져와도, 영상 자체에 60fps
포맷이 없는 경우 original.* 이 30fps 그대로 저장되어 "원본도 60fps 로
받아줘" 요청과 어긋났습니다.
다운로드 완료 직후 후처리 단계를 추가:
- editor.ts 에 `upscaleOriginalTo60Fps(dir, inputName)` 노출
· ffprobe 로 source fps 측정
· ≥60fps 면 그대로 두고 inputName 반환
· <60fps 면 minterpolate(mci, aobmc, bidir) 로 60fps 까지 끌어올려
`original.mp4` 로 저장하고 기존 파일 제거
· ffmpeg/ffprobe 없거나 보간 실패하면 원본 그대로 유지 (다운로드 살림)
- youtube.ts `runJob` 마지막에 이 함수를 호출하고, 새 파일명이 돌아오면
meta.originalFile 도 업데이트. 후처리 중 진행률을 99% 로 표시하고
실패해도 다운로드 자체는 성공으로 마감.
이로써 편집기에서 다시 보간할 일도 없어집니다 (이미 60fps 원본).
편집 코드 쪽 보호 분기는 직접 업로드 경로용 안전망으로 그대로 둡니다.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- youtube.ts: yt-dlp 에 `-S 'fps,res,br,ext'` 추가. 기본 정렬은 fps 를
가중치로 안 써서 60fps 가 있어도 30fps 를 잡아오는 일이 잦았는데,
이제 fps 1순위로 정렬해 가능한 한 부드러운 원본을 받는다. 60fps 가
아예 없는 영상은 자연스럽게 그 영상의 최고 fps 로 폴백.
- editor.ts: 편집본은 항상 60fps 이상이 되도록 보장.
· ffprobe 로 원본 fps 확인
· ≥60fps 이면 기존대로 stream copy 로 빠르게 trim
· <60fps 이면 minterpolate(mci, aobmc, bidir) 로 모션 보간해 60fps 로
재인코딩. mci 는 느리지만 단순 프레임 복제보다 모션이 훨씬 자연스러움.
· ffprobe 실패 시(확인 불가) 기존 동작 유지(stream copy → 재인코딩 폴백).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
외부 공유용으로 더 짧은 영상 파일 URL 을 제공:
/file/video/:videoId ← 신규 (영상 주소 복사 결과)
/api/video/:videoId/file ← 호환용 alias 로 유지 (기존 링크 안 깨지게)
/api/video/:videoId ← 메타 JSON (변경 없음)
핸들러는 한 함수를 두 라우트에 공유. 우클릭 "영상 주소 복사" 가
이제 새 짧은 경로를 클립보드에 넣음.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
문제: alpine/distroless 처럼 glibc 가 없는 도커 베이스 이미지에서는 PyInstaller
로 빌드된 yt-dlp_linux 의 동적 링커가 없어 execve 가 ENOENT 를 반환. 이전에는
npm run setup 이 통째로 실패해서 Docker 빌드를 차단했음.
수정:
- 다운로드는 됐지만 --version 검증이 실패하면 throw 하지 않고 안내만 출력 후
계속 진행. 못 쓰는 바이너리는 unlink 해서 혼동 방지.
- SKIP_YT_DLP=1 환경변수로 다운로드 자체를 건너뛸 수 있게 추가.
- 도커/PATH 설치 가이드를 warn 으로 같이 노출 (apt/apk/pip 명령).
- README 외부 의존 섹션에도 slim base / SKIP_YT_DLP 안내 추가.
src/youtube.ts 의 PATH fallback 은 그대로라 시스템에 yt-dlp 가 설치돼 있으면
런타임에 자동으로 그것을 사용합니다.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- 공개 폴더(/folder/:name): 새 ctxMenu + public-folder.js 로 우클릭 메뉴 신설
("영상 주소 복사" 만 포함). 좌클릭 재생 기능은 그대로 유지.
- 관리자 폴더(/op/folder/:name): 기존 ctxMenu 에 "영상 주소 복사" 항목과
folder.js 에 copyUrl 핸들러 추가.
- 양쪽 모두 navigator.clipboard.writeText(origin + "/player/" + id) 사용,
실패하면 hidden textarea + execCommand("copy") fallback, 그것도 실패하면
window.prompt 으로 직접 복사 안내. 성공 시 flashToast 로 피드백.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
관리자 폴더 화면에서도 영상 카드를 좌클릭하면 공개 폴더와 동일한
내부 팝업 플레이어로 재생되도록 함. 우클릭 컨텍스트 메뉴 (수정/이름
변경/삭제) 는 그대로 유지.
- 카드에 data-video-id 추가해 기존 player.js 가 인식하게 함
- 페이지에 playerOverlay HTML 과 player.js 스크립트 포함
- window.__SITE__.folder 도 함께 노출해 player.js 의 가정 충족
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
비디오의 src 가 HTML 인라인이라 loadedmetadata 가 스크립트 실행보다
먼저 끝나는 경우, 늦게 붙은 리스너가 영원히 못 받아 duration=0 으로
멈춰있던 문제 수정. readyState 즉시 검사 + durationchange 도 같이
구독하도록 변경.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
영상편집기에 lossless-cut 류 트림 타임라인 추가. 숫자 입력만 있던 기존 UI 를
시각적 재생바 + 좌/우 드래그 핸들 + 플레이헤드로 교체.
- 좌/우 파란 핸들을 끌어 in/out 점 설정 (pointer events 기반, 터치 지원)
- 흰색 플레이헤드가 영상 재생 위치 따라감
- 타임라인 빈 공간 클릭 → 그 지점으로 시킹
- "[ 시작점" / "끝점 ]" 버튼으로 현재 시점 마크
- "선택 재생" 으로 선택구간만 미리보기, "초기화" 로 전체 선택 복원
- 기존 숫자 입력은 보조 입력으로 유지하고 상태와 양방향 동기화
저장 페이로드는 그대로 (startSec/endSec). 서버측 ffmpeg 트림 로직 변경 없음.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
리뷰어 지적사항 반영:
- "abc" 같은 오타도 Infinity 로 풀리던 문제 수정. 잘못된 값은 기본 1 GiB 로
fallback 하고 경고 로그를 남김. 무제한은 "0" 또는 "Infinity" 만 명시적으로 인정.
- .env.example / README 의 "비우면 무제한" 표현을 코드 동작과 일치시켜
"비우면 기본 1 GiB" 로 정정.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
기본 업로드 한도를 1 GiB (1073741824 바이트) 로 설정. .env 의 UPLOAD_MAX_BYTES 로
바꿀 수 있고, 0 이나 Infinity 로 두면 무제한.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
도커는 사용자가 따로 만들어 쓸 예정이라 레포에서 제거합니다. README/.env.example
의 Docker 관련 안내도 같이 정리. .env 기반 PORT/HOST 설정은 직접 실행용으로 유지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
직접 실행과 Docker compose 가 같은 .env 한 파일로 PORT/HOST/SESSION_SECRET 등을
공유합니다. 컨테이너는 node:22-bookworm-slim 기반에 ffmpeg + 번들된 yt-dlp 포함,
data 볼륨 마운트로 영속화합니다.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/setup.mjs runs `npm install`, downloads the platform-specific
yt-dlp binary from GitHub releases to ./bin/yt-dlp (which src/youtube.ts
already prefers), checks for ffmpeg and prints install hints, then runs
`tsc`. One command replaces three for fresh checkouts.
While verifying setup, hit `MulterError: File too large` (LIMIT_FILE_SIZE)
on a 10 GB mkv upload, and ETXTBSY on freshly downloaded yt-dlp.
- ETXTBSY: the redirect path in downloadFile opened a writestream to the
destination before following the redirect, so the (unused) outer stream
still held the file open when the post-download spawnSync ran. Split
redirect-following from file writing so only the final 200 response
opens the destination file.
- LIMIT_FILE_SIZE: removed the hard-coded 4 GB cap. Upload limit now
defaults to Infinity and is configurable via UPLOAD_MAX_BYTES.
Wrapped multer's middleware so its errors (LIMIT_FILE_SIZE etc.) come
back as a clean 413 JSON instead of a stack trace from the global
error handler.
- Also disabled Node's default 5 minute requestTimeout so 10 GB uploads
over slow links don't get cut mid-stream. Configurable via
HTTP_REQUEST_TIMEOUT_MS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
.modalOverlay { display: flex } and .playerOverlay { display: flex }
were overriding the browser default [hidden] { display: none }, so the
"폴더 추가" modal stayed visible on /op/dashboard load and blocked all
other UI. Added a single [hidden] { display: none !important } rule so
the hidden attribute always wins regardless of later display rules.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
P1: views were emitting <%- JSON.stringify(...) %> directly inside <script>
tags. A video title like "</script><script>alert(1)</script>" would break
out of the script and inject HTML. Added res.locals.jsonForScript() that
escapes <, >, &, U+2028, U+2029 before output and switched all three
templates (op/editor.ejs, op/folder.ejs, folder.ejs) to use it.
P2: The internal popup player in /folder/:name always hit
/api/video/:id/file which returned the original. Made the file endpoint
default to the edited variant when present and only fall back to original
when ?edited=0 is given. Editor page passes ?edited=0 explicitly so the
operator always re-trims from the original. Standalone /player/:id no
longer needs the ?edited=1 hint.
Verified: rendered editor HTML escapes </script> payloads to \u003c/script,
default file endpoint serves edited.mp4 while ?edited=0 serves original.mp4.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>