diff --git a/server/app.py b/server/app.py index 8db4cc9..3b47f4c 100644 --- a/server/app.py +++ b/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")