붙여넣기 탭에 자동 탭과 같은 분석→추천→빌드 3단계 서버 흐름 추가

지금까지 붙여넣기 탭은 편집안 JSON을 받으면 바로 드래프트를 만들었는데,
자동 탭처럼 컷별 댓글 카드를 추천받아 검토한 뒤 만들 수 있도록
/paste/analyze(검증)·/paste/stream/{aid}(다운로드·무음·받아쓰기+h-lab
댓글 수집+추천)·/paste/build(보관한 상태로 드래프트만) 3종 엔드포인트를
추가했다. 추천에는 받아쓰기가 항상 필요해 analyze 단계는
remove_silence·asr_bottom을 강제로 켜고, build에서 asr_bottom을 끄면
받아쓰기 자막을 원래 JSON 자막으로 되돌려 압축 타임라인에 재매핑한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-05 10:46:15 +09:00
parent b9d81acfce
commit 85101867d5

View File

@ -20,7 +20,10 @@ from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from capcut_agent.pipeline import process_bg_template, process_paste from capcut_agent.pipeline import (
process_bg_template, process_paste, paste_analyze, paste_draft,
paste_steps as pipeline_paste_steps, _remap_caps,
)
from capcut_agent.paste import parse_paste from capcut_agent.paste import parse_paste
from capcut_agent.draft import DEFAULT_DRAFT_ROOT, list_drafts, repair_layers from capcut_agent.draft import DEFAULT_DRAFT_ROOT, list_drafts, repair_layers
from capcut_agent import comments as hlab from capcut_agent import comments as hlab
@ -45,6 +48,10 @@ JOBS: dict[str, dict] = {}
# analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음) # analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음)
ANALYSES: dict[str, dict] = {} ANALYSES: dict[str, dict] = {}
# 📋 붙여넣기 탭(새 흐름) — analysis_id → {"state", "places", "orig", "payload"}
# /paste/stream 이 채우고 /paste/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
PSTATES: dict[str, dict] = {}
_DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드") _DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드")
@ -213,7 +220,18 @@ async def stream(job_id: str) -> StreamingResponse:
yield _sse({"type": "error", "message": "알 수 없는 작업입니다."}) yield _sse({"type": "error", "message": "알 수 없는 작업입니다."})
return return
try: try:
if job.get("paste"): # 붙여넣기(JSON) 모드 if job.get("paste_state"): # 📋 붙여넣기 탭(새 흐름) — 이미 분석된 상태로 드래프트만
stream_iter = paste_draft(
job["paste_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),
card_cuts=job.get("card_cuts") or None,
bg_white=job.get("bg_white", False),
)
elif job.get("paste"): # 붙여넣기(JSON) 모드
stream_iter = process_paste( stream_iter = process_paste(
job["paste"], job["draft_name"], job["paste"], job["draft_name"],
video_scale=job.get("video_scale", 1.0), video_scale=job.get("video_scale", 1.0),
@ -680,6 +698,157 @@ async def auto_build(
"cards": len(cards)}) "cards": len(cards)})
@app.post("/paste/analyze")
async def paste_analyze_start(data: str = Form(...)) -> JSONResponse:
"""📋 붙여넣기 탭(새 흐름) 1단계 — 편집안 검증 후 분석 예약. 실제 작업은 /paste/stream 에서."""
try:
payload = parse_paste(data)
except ValueError as e:
return JSONResponse({"error": str(e)}, 400)
aid = hashlib.sha1(("paste|" + data).encode()).hexdigest()[:12]
ANALYSES[aid] = {"payload": payload}
return JSONResponse({"analysis_id": aid})
@app.get("/paste/stream/{aid}")
async def paste_stream(aid: str) -> StreamingResponse:
"""📋 붙여넣기 탭(새 흐름) — 다운로드·무음·받아쓰기 → h-lab 댓글 매칭 → 검토 화면용 result.
추천을 하려면 받아쓰기가 항상 필요하므로 remove_silence·asr_bottom 여기서
True 고정한다. 사용자가 화면에서 끄는 asr_bottom 옵션은 화면 자막에만
적용되며 /paste/build 단계에서 처리한다.
"""
a = ANALYSES.get(aid)
async def gen():
if not a:
yield _sse({"type": "error", "message": "알 수 없는 분석입니다."})
return
payload = a["payload"]
warnings: list[str] = []
yield _sse({"type": "manifest", "steps": pipeline_paste_steps(True) +
[{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"}]})
com_task = asyncio.create_task(
asyncio.to_thread(hlab.fetch_comments, payload["url"]))
state = None
try:
async for ev in paste_analyze(payload, "paste_" + aid,
remove_silence=True, asr_bottom=True):
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 = 압축 타임라인(카드·자막 추출용),
# orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
places = state["card_places"]
orig = [(s, e) for s, e, _, _ in state["cuts"]]
cuts, need, ai_failed = await asyncio.to_thread(
recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
if ai_failed:
warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다")
yield _sse({"type": "step", "id": "recommend", "status": "done",
"detail": f"{len(cuts)}컷 · 카드 {need}"})
PSTATES[aid] = {"state": state, "places": places, "orig": orig, "payload": payload}
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["timeline_dur"], 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("/paste/build")
async def paste_build(
aid: str = Form(...),
cards: list[UploadFile] = File(default=[]),
card_cuts: str = Form(""),
video_scale: str = Form("144"),
flip: str = Form(""),
scene: str = Form(""),
bg_white: str = Form(""),
cards_fixed: str = Form(""),
asr_bottom: str = Form("1"),
) -> JSONResponse:
"""📋 붙여넣기 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함)."""
st = PSTATES.get(aid)
if not st:
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
state = st["state"]
# asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로
# 되돌린다. state["bottom_caps"]는 analyze가 항상 asr_bottom=True로 돈 결과라
# 받아쓰기로 덮여 있다 — paste_analyze가 하던 것과 같은 식으로 원본 JSON 자막을
# 다시 만들고, 무음 제거로 압축된 타임라인에 맞춰 _remap_caps로 재매핑한다.
if not _truthy(asr_bottom):
dur = state["dur"]
bottom_caps = [(p0, min(p1, dur), b) for (p0, p1), (_, _, b, _)
in zip(state["placements"], state["cuts"]) if b and p0 < dur]
bottom_caps = _remap_caps(bottom_caps, state["video_clips"])
state = {**state, "bottom_caps": bottom_caps}
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치)
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 + "|" + asr_bottom + "|" + str(len(cards)))
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
cdir = ""
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)
JOBS[h] = {
"paste_state": state, "card_cuts": cut_map,
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
"comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
}
return JSONResponse({"job_id": h})
@app.post("/open-capcut") @app.post("/open-capcut")
async def open_capcut() -> JSONResponse: async def open_capcut() -> JSONResponse:
"""CapCut 실행 (Start Menu 바로가기 우선, 없으면 최신 버전 exe).""" """CapCut 실행 (Start Menu 바로가기 우선, 없으면 최신 버전 exe)."""