붙여넣기 탭에 자동 탭과 같은 분석→추천→빌드 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:
parent
b9d81acfce
commit
85101867d5
173
server/app.py
173
server/app.py
@ -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)."""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user