From 85101867d5dda42d1b57f978fb5a1fd02f9f3a7d Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Wed, 5 Aug 2026 10:46:15 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20?= =?UTF-8?q?=ED=83=AD=EC=97=90=20=EC=9E=90=EB=8F=99=20=ED=83=AD=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=B6=84=EC=84=9D=E2=86=92=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=E2=86=92=EB=B9=8C=EB=93=9C=203=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=ED=9D=90=EB=A6=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지 붙여넣기 탭은 편집안 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) --- server/app.py | 173 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 2 deletions(-) diff --git a/server/app.py b/server/app.py index 7b62f71..05237cf 100644 --- a/server/app.py +++ b/server/app.py @@ -20,7 +20,10 @@ from fastapi import FastAPI, File, Form, UploadFile from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse 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.draft import DEFAULT_DRAFT_ROOT, list_drafts, repair_layers from capcut_agent import comments as hlab @@ -45,6 +48,10 @@ JOBS: dict[str, dict] = {} # analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음) 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), "댓글카드") @@ -213,7 +220,18 @@ async def stream(job_id: str) -> StreamingResponse: yield _sse({"type": "error", "message": "알 수 없는 작업입니다."}) return 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( job["paste"], job["draft_name"], video_scale=job.get("video_scale", 1.0), @@ -680,6 +698,157 @@ async def auto_build( "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") async def open_capcut() -> JSONResponse: """CapCut 실행 (Start Menu 바로가기 우선, 없으면 최신 버전 exe)."""