capcut-agent/참고/ytcut/app/main.py
hehihoho3@gmail.com bf1b387d6d chore: git 저장소 초기화 (기존 코드 스냅샷)
컷별 댓글 추천 작업을 태스크 단위로 되돌릴 수 있게 버전관리를 시작한다.
.gitignore 로 영상·캐시(.downloads 2.7G, .comments 72M, .media 28M)와
비밀키(.gemini_key)를 제외했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:36:05 +09:00

468 lines
20 KiB
Python

"""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 ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
'stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" '
f'aria-hidden="true">{body}</svg>')
IC_FILM = _svg('<rect x="3" y="4" width="18" height="16" rx="2.2"/>'
'<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>')
IC_SCISSORS = _svg('<circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/>'
'<path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>')
IC_LINK = _svg('<path d="M9.5 14.5 14.5 9.5"/>'
'<path d="M11.5 7.3 13 5.8a3.7 3.7 0 0 1 5.2 5.2l-1.5 1.5"/>'
'<path d="M12.5 16.7 11 18.2a3.7 3.7 0 0 1-5.2-5.2l1.5-1.5"/>')
IC_DOWNLOAD = _svg('<path d="M12 3.5v11"/><path d="m7.5 10.5 4.5 4.5 4.5-4.5"/>'
'<path d="M5 20.5h14"/>')
IC_TRASH = _svg('<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/>'
'<path d="M6.5 7 7.5 20a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1L17.5 7"/>'
'<path d="M9.5 7V4.5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V7"/>')
IC_CLOCK = _svg('<circle cx="12" cy="12" r="8.4"/><path d="M12 7.5V12l3 1.8"/>')
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'<div class="empty">{IC_FILM}'
'<p>아직 자른 클립이 없어요.<br>위에서 첫 구간을 잘라보세요.</p></div>')
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(
'<li class="file">'
f'<span class="file-ic">{IC_FILM}</span>'
'<span class="file-meta">'
f'<span class="file-name" title="{esc}">{esc}</span>'
f'<span class="file-sub">{_human(size)} · {date}</span>'
'</span>'
f'<a class="iconbtn dl" href="{html.escape(href)}" '
f'aria-label="받기" title="받기">{IC_DOWNLOAD}</a>'
'<form class="inline" method="post" action="delete" '
'onsubmit="return confirm(\'이 파일을 삭제할까요?\')">'
f'<input type="hidden" name="name" value="{esc}">'
'<button class="iconbtn del" type="submit" '
f'aria-label="삭제" title="삭제">{IC_TRASH}</button>'
'</form>'
'</li>'
)
body = '<ul class="files">' + "".join(rows) + '</ul>'
return (
'<section class="files-sec">'
'<div class="files-head"><h2>파일</h2>'
f'<span class="count">{len(items)}개</span></div>'
f'{body}</section>'
)
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 = """<!doctype html><html lang="ko"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#17120E">
<title>ClipCut · 유튜브 구간 잘라받기</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap">
<style>__STYLE__</style></head>"""
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'<a class="dl-big" href="{html.escape(href)}">{IC_DOWNLOAD}파일 받기</a>'
f'<span class="dl-name">{html.escape(download_name)}</span>')
banner = (f'<div class="banner {cls}"><span class="msg">{html.escape(message)}</span>'
f'{dl}</div>')
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}
<body>
<div class="overlay" id="overlay">
<div class="sp"></div>
<p>구간을 자르는 중…<small>영상 길이에 따라 몇 초~몇 분 걸려요</small></p>
</div>
<div class="wrap">
<header class="brand">
<span class="logo">{IC_FILM}</span>
<div>
<h1>Clip<span class="ac">Cut</span></h1>
<div class="sub">유튜브 구간을 잘라 바로 받기</div>
</div>
</header>
<main class="card">
<div class="timeline" aria-hidden="true">
<div class="tl-track">
<span class="tl-seg"></span>
<span class="tl-cut">{IC_SCISSORS}</span>
</div>
<div class="tl-marks">
<span class="tl-pt"><b>IN</b><time class="mono" id="tcIn">{tc_in}</time></span>
<span class="tl-pt out"><b>OUT</b><time class="mono" id="tcOut">{tc_out}</time></span>
</div>
</div>
{banner}
<form method="post" action="cut" id="cutForm">
<div class="field">
<label for="url">유튜브 URL</label>
<div class="input-wrap">{IC_LINK}
<input id="url" name="url" type="url" required
placeholder="https://www.youtube.com/watch?v=…" value="{html.escape(url)}">
</div>
</div>
<div class="tc-row">
<div class="field tc">
<label for="start">시작</label>
<div class="input-wrap">{IC_CLOCK}
<input id="start" name="start" required placeholder="03:30"
inputmode="numeric" value="{html.escape(start)}">
</div>
</div>
<div class="field tc">
<label for="end">끝</label>
<div class="input-wrap">{IC_CLOCK}
<input id="end" name="end" required placeholder="06:20"
inputmode="numeric" value="{html.escape(end)}">
</div>
</div>
</div>
<p class="help">{IC_CLOCK}형식: 분:초(03:30) 또는 시:분:초(01:03:30)</p>
<button class="cut-btn" id="cutBtn" type="submit">
<span class="ic">{IC_SCISSORS}</span><span class="spin"></span>잘라서 받기
</button>
</form>
</main>
{_file_list_html()}
</div>
<script>{SCRIPT}</script>
</body></html>"""
FAVICON = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
'<rect width="24" height="24" rx="6" fill="#17120E"/>'
'<g fill="none" stroke="#FF5C39" stroke-width="1.7" stroke-linecap="round" '
'stroke-linejoin="round"><circle cx="7" cy="7" r="2.2"/><circle cx="7" cy="17" r="2.2"/>'
'<path d="M19 5 8.5 15.5M14 14l5 5M8.5 8.5 12 12"/></g></svg>'
)
@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)