"""YT Cut - 유튜브 구간 잘라 받기.
GET / 폼 + 서버 파일 목록(받기/삭제)
POST /cut yt-dlp로 지정한 시작~끝 구간만 mp4로 잘라 /downloads 에 저장
GET /download 저장된 파일을 브라우저로 내려받기
POST /delete 저장된 파일 삭제
"""
import html
import os
import re
import subprocess
from datetime import datetime
from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
app = FastAPI(title="ClipCut")
DOWNLOAD_DIR = "/downloads"
# MM:SS 또는 HH:MM:SS 만 허용 (shell 인자로 넘기기 전 검증)
TIME_RE = re.compile(r"^(?:\d{1,2}:)?\d{1,2}:[0-5]\d$")
TIMEOUT_SEC = 1800 # 30분
# ── 인라인 SVG 아이콘 (currentColor 사용) ────────────────────────────
def _svg(body: str) -> str:
return ('{body} ')
IC_FILM = _svg(' '
' ')
IC_SCISSORS = _svg(' '
' ')
IC_LINK = _svg(' '
' '
' ')
IC_DOWNLOAD = _svg(' '
' ')
IC_TRASH = _svg(' '
' '
' ')
IC_CLOCK = _svg(' ')
def _safe_name(name: str) -> str:
"""파일명만 허용(경로 탈출 차단)하고 DOWNLOAD_DIR 내부 실제 경로를 반환."""
if "/" in name or "\\" in name or name in ("", ".", ".."):
raise HTTPException(status_code=400, detail="잘못된 파일명")
path = os.path.join(DOWNLOAD_DIR, name)
base = os.path.realpath(DOWNLOAD_DIR)
if not os.path.realpath(path).startswith(base + os.sep):
raise HTTPException(status_code=400, detail="잘못된 경로")
return path
def _human(n: int) -> str:
size = float(n)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
def _list_files():
try:
names = os.listdir(DOWNLOAD_DIR)
except OSError:
return []
items = []
for name in names:
if name.startswith("."): # .metube 등 숨김 항목 제외
continue
path = os.path.join(DOWNLOAD_DIR, name)
if os.path.isfile(path):
items.append((name, os.path.getsize(path), os.path.getmtime(path)))
items.sort(key=lambda e: e[2], reverse=True) # 최신순
return items
def _file_list_html() -> str:
items = _list_files()
if not items:
body = (f'
{IC_FILM}'
'
아직 자른 클립이 없어요. 위에서 첫 구간을 잘라보세요.
')
else:
rows = []
for name, size, mtime in items:
esc = html.escape(name)
href = "download?name=" + quote(name)
date = datetime.fromtimestamp(mtime).strftime("%y.%m.%d %H:%M")
rows.append(
''
f'{IC_FILM} '
''
f'{esc} '
f'{_human(size)} · {date} '
' '
f'{IC_DOWNLOAD} '
''
' '
)
body = ''
return (
''
'
파일 '
f'{len(items)}개 '
f'{body} '
)
STYLE = """
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#17120E; --bg-soft:#1f1813; --panel:#241c16; --panel-2:#2c2219;
--line:#3a2d22; --line-soft:#2c2219;
--text:#F2EBE2; --muted:#A99C8F; --faint:#7C7064;
--accent:#FF5C39; --accent-soft:#ff5c3922; --accent-ink:#2a0d04;
--ok-bd:#3f7a4e; --ok-bg:#15321f; --ok-tx:#9fe6b4;
--err-bd:#7a3a35; --err-bg:#341b18; --err-tx:#f3b0aa;
--danger:#E0584E;
}
html{-webkit-text-size-adjust:100%}
body{font-family:Pretendard,system-ui,sans-serif;color:var(--text);
background:
radial-gradient(900px 380px at 50% -120px, #ff5c3914, transparent 70%),
var(--bg);
min-height:100vh;line-height:1.55;
padding:max(24px,env(safe-area-inset-top)) 16px 64px;
-webkit-font-smoothing:antialiased;}
.wrap{max-width:560px;margin:0 auto}
.mono{font-family:'JetBrains Mono',ui-monospace,monospace;font-variant-numeric:tabular-nums}
/* header */
.brand{display:flex;align-items:center;gap:13px;margin:6px 2px 22px;
animation:rise .5s .02s both}
.brand .logo{width:46px;height:46px;border-radius:13px;display:grid;place-items:center;
background:linear-gradient(150deg,var(--panel-2),var(--panel));
border:1px solid var(--line);color:var(--accent);flex:none;
box-shadow:inset 0 1px 0 #ffffff10}
.brand .logo svg{width:25px;height:25px}
.brand h1{font-size:20px;font-weight:800;letter-spacing:-.01em}
.brand h1 .ac{color:var(--accent)}
.brand .sub{font-size:13px;font-weight:500;color:var(--muted);margin-top:1px}
.card{background:linear-gradient(180deg,var(--panel),var(--bg-soft));
border:1px solid var(--line);border-radius:20px;padding:20px;
box-shadow:0 30px 70px -34px #000c, inset 0 1px 0 #ffffff08;
animation:rise .5s .06s both}
/* film-strip timeline signature */
.timeline{margin:2px 0 20px}
.tl-track{position:relative;height:48px;border-radius:9px;overflow:hidden;
background:linear-gradient(180deg,#1c1510,#171008);border:1px solid var(--line)}
.tl-track::before,.tl-track::after{content:"";position:absolute;left:0;right:0;height:8px;
background-image:repeating-linear-gradient(90deg,#0c0805 0 9px,transparent 9px 20px)}
.tl-track::before{top:5px}.tl-track::after{bottom:5px}
.tl-seg{position:absolute;top:0;bottom:0;left:30%;right:30%;
background:linear-gradient(180deg,var(--accent-soft),#ff5c3910);
border-left:2px solid var(--accent);border-right:2px solid var(--accent)}
.tl-seg::before{content:"";position:absolute;inset:0;margin:auto;width:26px;height:26px;
border-radius:50%;background:#1a120c;border:1px solid var(--accent);
display:grid;place-items:center}
.tl-cut{position:absolute;inset:0;margin:auto;width:16px;height:16px;color:var(--accent);
z-index:1}
.tl-marks{display:flex;align-items:flex-end;justify-content:space-between;margin-top:11px}
.tl-pt{display:flex;flex-direction:column;gap:2px}
.tl-pt.out{align-items:flex-end}
.tl-pt b{font-size:10px;font-weight:700;letter-spacing:.16em;color:var(--faint)}
.tl-pt time{font-size:19px;font-weight:600;color:var(--text)}
/* fields */
.field{margin-top:15px}
.field>label{display:block;font-size:12px;font-weight:600;letter-spacing:.02em;
color:var(--muted);margin-bottom:7px}
.input-wrap{display:flex;align-items:center;gap:10px;background:var(--bg-soft);
border:1px solid var(--line);border-radius:12px;padding:0 13px;
transition:border-color .15s,box-shadow .15s}
.input-wrap:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.input-wrap>svg{width:18px;height:18px;color:var(--faint);flex:none}
.input-wrap input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--text);
font-family:inherit;font-size:15px;padding:13px 0}
.input-wrap input::placeholder{color:var(--faint)}
.tc-row{display:flex;gap:12px}
.tc-row .field{flex:1;margin-top:15px}
.tc input{font-family:'JetBrains Mono',monospace;font-size:15px;letter-spacing:.03em}
.help{font-size:12px;color:var(--faint);margin-top:9px;display:flex;gap:6px;align-items:center}
.help svg{width:14px;height:14px;flex:none}
/* primary cut button */
.cut-btn{width:100%;margin-top:20px;display:flex;gap:9px;align-items:center;justify-content:center;
padding:15px;font-family:inherit;font-size:16px;font-weight:700;color:var(--accent-ink);
background:var(--accent);border:0;border-radius:13px;cursor:pointer;
box-shadow:0 8px 24px -8px #ff5c3977;
transition:filter .15s,box-shadow .2s,transform .1s}
.cut-btn:hover{filter:brightness(1.06);box-shadow:0 12px 30px -9px #ff5c3999}
.cut-btn:active{transform:translateY(1px)}
.cut-btn svg{width:20px;height:20px}
.cut-btn .spin{display:none;width:18px;height:18px;border-radius:50%;
border:2.5px solid #2a0d0455;border-top-color:var(--accent-ink);animation:spin .7s linear infinite}
.cut-btn.loading .ic{display:none}
.cut-btn.loading .spin{display:block}
/* result banner */
.banner{margin-top:18px;padding:14px 16px;border-radius:13px;border:1px solid;
display:flex;flex-direction:column;gap:10px;animation:rise .35s both}
.banner.ok{border-color:var(--ok-bd);background:var(--ok-bg)}
.banner.err{border-color:var(--err-bd);background:var(--err-bg)}
.banner .msg{font-size:14px;font-weight:600;white-space:pre-wrap;word-break:break-all}
.banner.ok .msg{color:var(--ok-tx)}
.banner.err .msg{color:var(--err-tx)}
.dl-big{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;
padding:11px 17px;border-radius:11px;background:var(--accent);color:var(--accent-ink);
font-weight:700;font-size:14px;text-decoration:none;
box-shadow:0 8px 22px -10px #ff5c3988;transition:filter .15s,transform .1s}
.dl-big:hover{filter:brightness(1.06)} .dl-big:active{transform:translateY(1px)}
.dl-big svg{width:18px;height:18px}
.dl-name{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--ok-tx);
opacity:.8;word-break:break-all}
/* file list */
.files-sec{margin-top:26px;animation:rise .5s .1s both}
.files-head{display:flex;align-items:baseline;justify-content:space-between;
padding:0 2px 4px}
.files-head h2{font-size:15px;font-weight:700}
.files-head .count{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--muted)}
.files{list-style:none}
.file{display:flex;align-items:center;gap:12px;padding:12px 6px;
border-top:1px solid var(--line-soft)}
.file:first-child{border-top:0}
.file-ic{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;
background:var(--bg-soft);border:1px solid var(--line);color:var(--faint);flex:none}
.file-ic svg{width:19px;height:19px}
.file-meta{flex:1;min-width:0}
.file-name{display:block;font-size:14px;font-weight:500;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-sub{font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--faint);margin-top:3px}
.inline{display:inline;margin:0}
.iconbtn{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:11px;
border:1px solid var(--line);background:var(--bg-soft);color:var(--muted);cursor:pointer;
transition:.15s}
.iconbtn svg{width:18px;height:18px}
.iconbtn.dl:hover{color:var(--accent);border-color:var(--accent)}
.iconbtn.del:hover{color:#fff;background:var(--danger);border-color:var(--danger)}
.empty{display:flex;flex-direction:column;align-items:center;gap:12px;
padding:36px 0 30px;color:var(--faint);text-align:center;font-size:13.5px}
.empty svg{width:34px;height:34px;opacity:.7}
/* loading overlay */
.overlay{position:fixed;inset:0;display:none;flex-direction:column;gap:16px;
align-items:center;justify-content:center;z-index:50;text-align:center;
background:rgba(15,11,8,.72);backdrop-filter:blur(6px)}
.overlay.show{display:flex}
.overlay .sp{width:42px;height:42px;border-radius:50%;
border:3px solid #ffffff1f;border-top-color:var(--accent);animation:spin .8s linear infinite}
.overlay p{font-size:14px;font-weight:600;color:var(--text)}
.overlay small{display:block;color:var(--muted);font-weight:400;margin-top:3px}
a,button{-webkit-tap-highlight-color:transparent}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion:reduce){
*{animation:none!important;transition:none!important}
}
"""
SCRIPT = """
(function(){
var s=document.getElementById('start'), e=document.getElementById('end');
var ti=document.getElementById('tcIn'), to=document.getElementById('tcOut');
function up(){ if(ti) ti.textContent=(s.value.trim()||'--:--');
if(to) to.textContent=(e.value.trim()||'--:--'); }
if(s&&e){ s.addEventListener('input',up); e.addEventListener('input',up); }
var f=document.getElementById('cutForm'), ov=document.getElementById('overlay'),
b=document.getElementById('cutBtn');
if(f){ f.addEventListener('submit',function(){
if(b) b.classList.add('loading'); if(ov) ov.classList.add('show'); }); }
})();
"""
HEAD = """
ClipCut · 유튜브 구간 잘라받기
"""
def page(message: str = "", ok: bool = False, url: str = "",
start: str = "", end: str = "", download_name: str = "") -> str:
banner = ""
if message:
cls = "ok" if ok else "err"
dl = ""
if download_name:
href = "download?name=" + quote(download_name)
dl = (f'{IC_DOWNLOAD}파일 받기 '
f'{html.escape(download_name)} ')
banner = (f'{html.escape(message)} '
f'{dl}
')
tc_in = html.escape(start) if start else "03:30"
tc_out = html.escape(end) if end else "06:20"
head = HEAD.replace("__STYLE__", STYLE)
return f"""{head}
구간을 자르는 중…영상 길이에 따라 몇 초~몇 분 걸려요
{IC_SCISSORS}
IN {tc_in}
OUT {tc_out}
{banner}
{_file_list_html()}
"""
FAVICON = (
''
' '
' '
' '
)
@app.get("/favicon.svg")
@app.get("/favicon.ico")
def favicon() -> Response:
return Response(FAVICON, media_type="image/svg+xml")
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return page()
@app.post("/cut", response_class=HTMLResponse)
def cut(url: str = Form(...), start: str = Form(...), end: str = Form(...)) -> str:
url, start, end = url.strip(), start.strip(), end.strip()
if not (url.startswith("http://") or url.startswith("https://")):
return page("올바른 URL이 아닙니다.", url=url, start=start, end=end)
if not TIME_RE.match(start) or not TIME_RE.match(end):
return page("시간 형식이 올바르지 않습니다. 예) 03:30 또는 01:03:30",
url=url, start=start, end=end)
section = f"*{start}-{end}"
suffix = f"{start}-{end}".replace(":", "")
outtmpl = f"{DOWNLOAD_DIR}/%(title)s_{suffix}.%(ext)s"
cmd = [
"yt-dlp",
"--download-sections", section,
"-f", "bv*+ba/b",
"--merge-output-format", "mp4",
"--no-playlist",
"--no-simulate", "--print", "after_move:filepath", # 최종 저장 경로 출력
"-o", outtmpl,
url,
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_SEC)
except subprocess.TimeoutExpired:
return page(f"처리 시간이 초과됐습니다(>{TIMEOUT_SEC // 60}분).",
url=url, start=start, end=end)
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip()[-1500:]
return page(f"실패했습니다.\n\n{tail}", url=url, start=start, end=end)
final_path = ""
for line in (proc.stdout or "").splitlines():
line = line.strip()
if line.startswith(DOWNLOAD_DIR + "/"):
final_path = line
download_name = os.path.basename(final_path) if final_path else ""
return page(f"완료! {start} ~ {end} 구간을 잘랐어요.", ok=True,
download_name=download_name)
@app.get("/download")
def download(name: str) -> FileResponse:
path = _safe_name(name)
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
return FileResponse(path, filename=name, media_type="application/octet-stream")
@app.post("/delete")
def delete(name: str = Form(...)) -> RedirectResponse:
path = _safe_name(name)
if os.path.isfile(path):
os.remove(path)
return RedirectResponse("/", status_code=303)