유튜브 구간 탭에 분석→추천→빌드 3단계 서버 흐름 추가, 옛 단일 엔드포인트 제거

붙여넣기 탭(Task 3)과 같은 구조로 /yt/analyze·/yt/stream/{aid}·/yt/build를
추가해 다운로드 전에 댓글 매칭 검토가 가능하게 한다. bg_analyze/bg_draft로
쪼개고 state["places"]/state["ranges_sec"]로 압축·원본 두 좌표계를 분리해
recommend.cuts_from_state에 넘겼다. 옛 /yt/comments·/youtube는 새 흐름이
대체하므로 삭제.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-05 11:48:08 +09:00
parent 447e8808ff
commit 678d3f20e5

View File

@ -23,6 +23,7 @@ from fastapi.staticfiles import StaticFiles
from capcut_agent.pipeline import (
process_bg_template, process_paste, paste_analyze, paste_draft,
paste_steps as pipeline_paste_steps, _remap_caps,
bg_analyze, bg_draft, bg_steps, _cards_by_cut, _card_paths,
)
from capcut_agent.paste import parse_paste
from capcut_agent.draft import DEFAULT_DRAFT_ROOT, list_drafts, repair_layers
@ -52,6 +53,10 @@ ANALYSES: dict[str, dict] = {}
# /paste/stream 이 채우고 /paste/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
PSTATES: dict[str, dict] = {}
# ▶ 유튜브 구간 탭(새 흐름) — analysis_id → {"state"}
# /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
YSTATES: dict[str, dict] = {}
_DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드")
@ -135,54 +140,6 @@ async def upload(
return JSONResponse({"job_id": h, "draft_name": JOBS[h]["draft_name"]})
@app.post("/youtube")
async def youtube(
url: str = Form(...),
ranges: str = Form(""),
start: str = Form(""),
end: str = Form(""),
title_top: str = Form(""),
title_main: str = Form(""),
channel: str = Form(""),
video_scale: str = Form("100"),
flip: str = Form(""),
scene: str = Form(""),
comments_dir: str = Form(""),
bg_white: str = Form(""),
cards_fixed: str = Form(""),
cards: list[UploadFile] = File(default=[]),
) -> JSONResponse:
"""유튜브 URL + 여러 구간으로 작업 생성. ranges=JSON [["mm:ss","mm:ss"],...].
ranges 없으면 start/end 단일 구간으로 폴백(하위호환).
cards(댓글 매칭에서 캡처한 PNG들) 오면 폴더 지정보다 우선한다.
"""
rng = _parse_ranges(ranges) or ([(start.strip(), end.strip())] if start and end else [])
if not rng:
return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400)
sig = url + "|" + "|".join(f"{s}-{e}" for s, e in rng)
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
if cards:
cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재실행 시 이전 카드 잔재 제거
shutil.rmtree(cdir, ignore_errors=True)
os.makedirs(cdir, exist_ok=True)
for i, f in enumerate(cards, 1):
body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body)
comments_dir = cdir
JOBS[h] = {
"path": None, "draft_name": f"yt_{h}",
"youtube": {"url": url.strip(), "ranges": rng},
"title_top": title_top, "title_main": title_main, "channel": channel,
"video_scale": _scale(video_scale), "flip": _truthy(flip),
"scene": _truthy(scene), "comments_dir": comments_dir, "bg_white": _truthy(bg_white),
"cards_fixed": _truthy(cards_fixed),
}
return JSONResponse({"job_id": h})
@app.post("/paste")
async def paste(
data: str = Form(...),
@ -236,6 +193,20 @@ async def stream(job_id: str) -> StreamingResponse:
card_cuts=job.get("card_cuts") or None,
bg_white=job.get("bg_white", False),
)
elif job.get("bg_state"): # ▶ 유튜브 구간 탭(새 흐름) — 이미 분석된 상태로 드래프트만
# paste_state 와 같은 이유로 draft 단독 manifest 를 여기서 새로 낸다.
yield _sse({"type": "manifest",
"steps": [{"id": "draft", "label": "템플릿 드래프트 생성"}]})
stream_iter = bg_draft(
job["bg_state"],
video_scale=job.get("video_scale", 1.0),
flip_horizontal=job.get("flip", False),
scene_split=job.get("scene", False),
comments_dir=job.get("comments_dir", ""),
cards_fixed=job.get("cards_fixed", False),
bg_white=job.get("bg_white", False),
comment_cards=job.get("comment_cards"),
)
elif job.get("paste"): # 붙여넣기(JSON) 모드
stream_iter = process_paste(
job["paste"], job["draft_name"],
@ -274,33 +245,179 @@ async def stream(job_id: str) -> StreamingResponse:
"X-Accel-Buffering": "no"})
@app.post("/yt/comments")
async def yt_comments(url: str = Form(...), ranges: str = Form(...)) -> JSONResponse:
"""유튜브 구간 탭 — 구간 언급 댓글 매칭. 규칙은 자동 탭과 동일:
= 어느 구간이든 언급(좋아요순), = : 없는 댓글(좋아요순), 전체 전송."""
@app.post("/yt/analyze")
async def yt_analyze(
url: str = Form(...),
ranges: str = Form(...),
title_top: str = Form(""),
title_main: str = Form(""),
channel: str = Form(""),
) -> JSONResponse:
"""▶ 유튜브 구간 탭(새 흐름) 1단계 — URL·구간 검증 후 분석 예약. 실제 작업은 /yt/stream 에서.
title_top/title_main/channel 여기서 받는다: bg_analyze title_main
Gemini 자막 교정 힌트로 쓰고, /yt/stream GET(SSE)이라 요청 본문이 없어
지금 받으면 넘길 곳이 없다. video_scale 렌더 전용 옵션은 /yt/build 에서.
"""
u = url.strip()
if not (u.startswith("http://") or u.startswith("https://")):
return JSONResponse({"error": "유튜브 주소를 입력하세요."}, 400)
try:
rng = [(float(s), float(e)) for s, e in json.loads(ranges)]
rng = [(s, e) for s, e in rng if e > s]
except (json.JSONDecodeError, TypeError, ValueError):
rng = []
rng = _parse_ranges(ranges)
if not rng:
return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400)
aid = hashlib.sha1(("yt|" + u + "|" + ranges).encode()).hexdigest()[:12]
ANALYSES[aid] = {"url": u, "ranges": rng, "title_top": title_top,
"title_main": title_main, "channel": channel}
return JSONResponse({"analysis_id": aid})
@app.get("/yt/stream/{aid}")
async def yt_stream(aid: str) -> StreamingResponse:
"""▶ 유튜브 구간 탭(새 흐름) — 구간 다운로드·무음·받아쓰기 → h-lab 댓글 매칭 → 검토 화면용 result."""
a = ANALYSES.get(aid)
async def gen():
if not a:
yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
return
url = a["url"]
ranges = a["ranges"]
youtube = {"url": url, "ranges": ranges}
warnings: list[str] = []
# draft 스텝은 이 단계(analyze)에서 안 돈다 — /yt/build 때 별도 스트림으로 실행되므로
# 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다.
yield _sse({"type": "manifest", "steps":
[s for s in bg_steps(youtube) if s["id"] != "draft"] +
[{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"}]})
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
yield _sse({"type": "step", "id": "comments", "status": "start"})
state = None
try:
comments = await asyncio.to_thread(hlab.fetch_comments, u)
async for ev in bg_analyze(None, "yt_" + aid,
title_top=a.get("title_top", ""),
title_main=a.get("title_main", ""),
channel=a.get("channel", ""),
youtube=youtube):
if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다
state = ev["state"]
continue
if ev.get("type") == "error":
com_task.cancel()
yield _sse(ev)
return
yield _sse(ev)
except Exception as exc: # noqa: BLE001 — 다운로드 등 실패는 드래프트 생성을 막는다
com_task.cancel()
yield _sse({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
return
if state is None:
com_task.cancel()
yield _sse({"type": "error", "message": "분석 상태를 만들지 못했습니다."})
return
comments: list[dict] = []
try:
comments = await com_task
yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": f"{len(comments)}"})
except Exception as exc: # noqa: BLE001 — 댓글 실패는 진행을 막지 않는다
warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": "실패(생략)"})
yield _sse({"type": "step", "id": "recommend", "status": "start"})
# ⚠ 좌표계 둘: places/captions = 압축 타임라인(카드·자막 추출용),
# ranges_sec = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
# bg_analyze 의 무음 제거는 항상 켜져 있어 state["places"] 는 이미 압축 좌표다
# (_remap_placements 를 여기서 또 부르면 두 번 압축된다 — 부르지 않는다).
places = state["places"]
orig = state["ranges_sec"]
try:
cuts, need, ai_failed = await asyncio.to_thread(
recommend.cuts_from_state, places, orig, state["captions"], comments)
if ai_failed:
warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다")
except Exception as exc: # noqa: BLE001
return JSONResponse({"error": f"h-lab 연결 실패: {exc}"}, 502)
total = sum(e - s for s, e in rng)
matched = hlab.match_ranges(comments, rng)
# cuts=None(빈 리스트 아님)이어야 화면이 "컷 0개"로 비지 않고 ⭐/ 폴백으로 넘어간다.
cuts, need = None, max(1, int(state["total"] // 3))
warnings.append(f"컷별 추천 실패 — 컷 정보 없이 진행합니다 ({type(exc).__name__}: {exc})")
yield _sse({"type": "step", "id": "recommend", "status": "done",
"detail": (f"{len(cuts)}컷 · 카드 {need}" if cuts is not None
else f"폴백 · 카드 {need}")})
YSTATES[aid] = {"state": state}
no_ts = [c for c in comments if not c["times"]]
return JSONResponse({
"need": max(1, int(total // 3)), "total": round(total, 1),
matched = hlab.match_ranges(comments, orig) if comments else []
yield _sse({"type": "result", "cuts": cuts, "need": need,
"total": round(state["total"], 1),
"cutRanges": [{"start": s, "end": e} for s, e in orig],
"matched": matched,
"candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
"comments": comments,
})
"comments": comments, "warnings": warnings})
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@app.post("/yt/build")
async def yt_build(
aid: str = Form(...),
cards: list[UploadFile] = File(default=[]),
card_cuts: str = Form(""),
video_scale: str = Form("100"),
flip: str = Form(""),
scene: str = Form(""),
bg_white: str = Form(""),
cards_fixed: str = Form(""),
comments_dir: str = Form(""),
) -> JSONResponse:
"""▶ 유튜브 구간 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함)."""
st = YSTATES.get(aid)
if not st:
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
state = st["state"]
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백)
cut_map: list[int] = []
try:
parsed = json.loads(card_cuts) if card_cuts.strip() else []
if isinstance(parsed, list):
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
except (json.JSONDecodeError, ValueError, TypeError):
cut_map = []
sig = (aid + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
+ bg_white + "|" + cards_fixed + "|" + str(len(cards)))
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
# 카드가 오면 폴더 지정보다 우선(/paste/build 와 같은 규칙). 카드를 하나도 안 골랐으면
# 화면에서 넘어온 폴더 경로를 그대로 써서 예전(폴더 지정) 동작으로 하위호환한다.
cdir = comments_dir.strip()
if cards:
cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거
shutil.rmtree(cdir, ignore_errors=True)
os.makedirs(cdir, exist_ok=True)
for i, f in enumerate(cards, 1):
body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body)
# ⚠ state["places"] 는 bg_analyze 가 이미 무음 제거를 반영해 압축한 좌표다.
# 여기서 또 재매핑하지 않는다 — 카드 시간은 이 좌표를 그대로 컷 구간으로 쓴다.
cut_cards = _cards_by_cut(_card_paths(cdir), cut_map, state["places"], state["total"],
fixed=_truthy(cards_fixed))
JOBS[h] = {
"bg_state": state,
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
"comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
# cut_cards 가 비면 None → bg_draft 가 comments_dir 기준 전체 균등 배치로 폴백
# (/paste/build·paste_draft 의 `cards = cut_cards or _load_comment_cards(...)`와 동일 규칙).
"comment_cards": cut_cards or None,
}
return JSONResponse({"job_id": h})
@app.post("/auto/analyze")