유튜브 구간 탭에 분석→추천→빌드 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:
parent
447e8808ff
commit
678d3f20e5
255
server/app.py
255
server/app.py
@ -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:
|
||||
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
|
||||
# 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"]]
|
||||
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, "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:
|
||||
comments = await asyncio.to_thread(hlab.fetch_comments, u)
|
||||
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)
|
||||
no_ts = [c for c in comments if not c["times"]]
|
||||
return JSONResponse({
|
||||
"need": max(1, int(total // 3)), "total": round(total, 1),
|
||||
"matched": matched,
|
||||
"candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
|
||||
"comments": comments,
|
||||
})
|
||||
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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user