diff --git a/assets/politics_factlab_logo.png b/assets/politics_factlab_logo.png new file mode 100644 index 0000000..96dd24e Binary files /dev/null and b/assets/politics_factlab_logo.png differ diff --git a/capcut_agent/draft.py b/capcut_agent/draft.py index f72aa8c..b248231 100644 --- a/capcut_agent/draft.py +++ b/capcut_agent/draft.py @@ -186,6 +186,7 @@ def build_bg_template_draft( comment_y: Optional[float] = None, comment_top: Optional[float] = None, bg_white: bool = False, + logo_image: Optional[str] = None, canvas: Tuple[int, int] = (SHORT_W, SHORT_H), draft_root: str = DEFAULT_DRAFT_ROOT, ) -> str: @@ -200,6 +201,7 @@ def build_bg_template_draft( raise ValueError("video_clips 가 비어 있습니다.") cw, ch = canvas has_bg = bool(bg_image_path) + politics_style = bool(logo_image) # 제목 두 줄(윗줄 포인트색 + 아랫줄 흰색)은 비어도 자리표시로 항상 표시 → 캡컷에서 편집 if not title_main: title_main = "메인제목" @@ -208,11 +210,14 @@ def build_bg_template_draft( folder = p.DraftFolder(draft_root) script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True) - # 영상 타입 트랙: [bg] → main → frame(위). 텍스트는 자동으로 더 위. + # 영상 타입 트랙: [bg] → main → frame(위) → [logo]. if has_bg: script.add_track(p.TrackType.video, "bg", relative_index=0) - script.add_track(p.TrackType.video, "main", relative_index=1 if has_bg else 0) - script.add_track(p.TrackType.video, "frame", relative_index=2 if has_bg else 1) + base_ri = 1 if has_bg else 0 + script.add_track(p.TrackType.video, "main", relative_index=base_ri) + script.add_track(p.TrackType.video, "frame", relative_index=base_ri + 1) + if logo_image and os.path.isfile(logo_image): + script.add_track(p.TrackType.video, "logo", relative_index=base_ri + 2) if comment_cards: # 댓글 카드(검은 띠 위) — 프레임보다 위 레이어 script.add_track(p.TrackType.video, "comment", relative_index=3 if has_bg else 2) # 텍스트 트랙은 각각 다른 층(render_index)으로 → 겹침/누락 방지 @@ -270,7 +275,8 @@ def build_bg_template_draft( return [ln.strip() for ln in t.split("\n") if ln.strip()] # 배경 박스 없음(background 인자 생략) + 흰색 + 검은 획 40. 폰트는 저장 후 제주명조체 주입. - cap_style = p.TextStyle(size=CAPTION_SIZE, bold=False, color=CAPTION_COLOR, align=1) + cap_style = p.TextStyle(size=18.0 if politics_style else CAPTION_SIZE, + bold=False, color=CAPTION_COLOR, align=1) cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=CAPTION_BORDER_W) for ts, te, text in captions: if _us(te) - _us(ts) <= 0 or not text: @@ -310,6 +316,14 @@ def build_bg_template_draft( frame_mat = p.VideoMaterial(frame_image_path) script.add_segment(p.VideoSegment(frame_mat, p.Timerange(0, total_us)), "frame") + # 정치팩트랩 로고: 기준 영상처럼 하단 검정 영역 중앙에 고정한다. + if logo_image and os.path.isfile(logo_image): + script.add_segment(p.VideoSegment( + p.VideoMaterial(logo_image), p.Timerange(0, total_us), + clip_settings=p.ClipSettings(scale_x=0.27, scale_y=0.27, + transform_x=0.0, transform_y=_ty(1640, ch)), + ), "logo") + # 댓글 카드: 하단 띠에 순서대로. 확대 89% 고정, X 0. # 세로 위치는 comment_top(윗변 픽셀)이 주어지면 **카드마다** 계산 — # 카드 이미지 높이가 제각각이라 중앙값 하나로는 "영상 바로 아래"에 못 붙인다. @@ -342,17 +356,20 @@ def build_bg_template_draft( # 흰 배경일 땐: 메인제목 외곽선 두께 50, 채널 글씨 검정(안 보임 방지), 서브제목 그림자. main_border_w = 50.0 if bg_white else 18.0 channel_color = (0.0, 0.0, 0.0) if bg_white else (1.0, 1.0, 1.0) - if title_top: # 서브제목: 주황, 크기 14 + if title_top: # 정치: 흰색 / 예능: 주황 script.add_segment(p.TextSegment( title_top, full, - style=p.TextStyle(size=14.0, bold=True, color=TITLE_ACCENT, align=1), + style=p.TextStyle(size=17.0 if politics_style else 14.0, bold=True, + color=((1.0, 1.0, 1.0) if politics_style else TITLE_ACCENT), align=1), border=p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0), clip_settings=p.ClipSettings(transform_y=title_top_y), ), "title_top") - if title_main: # 메인제목: 흰색, 크기 18 + if title_main: # 정치: 빨강 / 예능: 흰색 script.add_segment(p.TextSegment( title_main, full, - style=p.TextStyle(size=18.0, bold=True, color=(1.0, 1.0, 1.0), align=1), + style=p.TextStyle(size=20.0 if politics_style else 18.0, bold=True, + color=((1.0, 0.18, 0.18) if politics_style else (1.0, 1.0, 1.0)), + align=1), border=p.TextBorder(color=(0.0, 0.0, 0.0), width=main_border_w), clip_settings=p.ClipSettings(transform_y=title_main_y), ), "title_main") @@ -360,15 +377,19 @@ def build_bg_template_draft( # 이미지 설정: 글꼴 크기 10, 가운데 (흰 배경이면 검정) script.add_segment(p.TextSegment( channel, full, - style=p.TextStyle(size=10.0, bold=False, color=channel_color, align=1), + style=p.TextStyle(size=12.0 if politics_style else 10.0, bold=False, + color=channel_color, align=1), clip_settings=p.ClipSettings(transform_y=channel_y), ), "channel") script.save() draft_dir = os.path.join(draft_root, draft_name) _apply_font_to_texts(draft_dir, KOTRA_BOLD) # 모든 텍스트에 코트라 볼드체 주입 - _apply_font_to_track(draft_dir, "caption", JEJU_MYEONGJO) # 하단 자막만 제주명조체 - # 하단 자막은 그림자 없음(획 40 으로 대체) — _apply_shadow_to_track 호출하지 않는다. + _apply_font_to_track(draft_dir, "caption", + CAFE24_DANJUNGHAE if politics_style else JEJU_MYEONGJO) + if politics_style: + _apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW) + # 예능 하단 자막은 그림자 없음(획 40 으로 대체). _lock_tracks(draft_dir, LOCK_TRACKS) # 오버레이·제목 트랙 잠금(편집 중 레이어 꼬임 방지) if bg_white and title_top: # 서브제목에 그림자 주입(캡컷 실측 형식) _apply_shadow_to_text(draft_dir, title_top, _TEXT_SHADOW) @@ -381,7 +402,7 @@ def build_bg_template_draft( # main(영상 편집)·caption(자막 편집)·effect 는 편집해야 하므로 잠그지 않는다. # bg(흰 배경)도 잠그지 않는다 — 맨 아래 레이어라 꼬여도 화면에 영향이 없고, # 영상 길이를 늘릴 때 같이 늘려야 해서 잠겨 있으면 불편하다(사용자 요청). -LOCK_TRACKS = ("frame", "comment", "title_top", "title_main", "channel") +LOCK_TRACKS = ("frame", "comment", "logo", "title_top", "title_main", "channel") # 비디오 트랙 이름 → 정상 render_index(아래→위). 생성 시 pycapcut 이 매기는 값과 동일. @@ -638,6 +659,16 @@ JEJU_MYEONGJO = { "id": "7480851064179133702", } +# 카페24 단정해 — 정치 숏폼 자막. CapCut에서 사용자가 맞춘 기준 드래프트의 +# 실제 폰트 소재 ID/캐시 경로를 그대로 사용한다. +CAFE24_DANJUNGHAE = { + "path": os.path.join( + os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect", + "7528305055972199681", "0e4893968fe2d82714917f69c69826aa", "font.ttf", + ).replace("\\", "/"), + "id": "7528305055972199681", +} + def _text_material_ids(j: dict, track_name: str) -> set: """지정 텍스트 트랙 세그먼트들의 material_id 집합.""" diff --git a/capcut_agent/pipeline.py b/capcut_agent/pipeline.py index 44b1101..03d29ff 100644 --- a/capcut_agent/pipeline.py +++ b/capcut_agent/pipeline.py @@ -456,6 +456,8 @@ async def process_bg_template( CANVAS_H = 1920 VIDEO_TOP = 323 # 영상 창 시작 = 위 흰 띠가 끝나는 지점 VIDEO_BOTTOM = 1122 # 영상 창 끝 = 아래 흰 띠가 시작하는 지점 +POLITICS_VIDEO_TOP = 470 +POLITICS_VIDEO_BOTTOM = 1450 TITLE_TOP_Y = 109 # 서브제목(주황) 중앙 TITLE_MAIN_Y = 252 # 메인제목(흰색) 중앙 CAPTION_GAP = 72 # 하단 자막 중앙 = VIDEO_BOTTOM − 이 값 (영상 창 안쪽 아래) @@ -464,7 +466,7 @@ COMMENT_TOP = VIDEO_BOTTOM # 댓글 카드 윗변 = 영상 바로 아래(딱 CHANNEL_RATIO = 0.85 # 출처: 아래 띠에서 85% 내려간 지점 -def _template_pos(white: bool = False): +def _template_pos(white: bool = False, profile: str = "entertainment"): """배경템플릿 프레임/배경 생성 + 위치 dict 계산. (frame_path, bg_path, pos) 반환. white=True 면 상하 띠·빈 곳을 흰색으로(흰 띠 프레임 + 흰 배경 레이어), @@ -472,22 +474,26 @@ def _template_pos(white: bool = False): 좌표는 전부 위 레이아웃 상수에서 파생 — 한 곳만 고치면 된다. """ band = (255, 255, 255) if white else (0, 0, 0) - fname = "frame_template_white.png" if white else "frame_template.png" - frame = os.path.join(_ROOT, "assets", fname) + suffix = "_politics" if profile == "politics" else "" + fname = f"frame_template{suffix}{'_white' if white else ''}.png" + frame_root = os.path.join(_ROOT, ".cache", "frames") if profile == "politics" else os.path.join(_ROOT, "assets") + frame = os.path.join(frame_root, fname) os.makedirs(os.path.dirname(frame), exist_ok=True) - make_frame(frame, top_bar=VIDEO_TOP, bottom_top=VIDEO_BOTTOM, band_color=band) + video_top = POLITICS_VIDEO_TOP if profile == "politics" else VIDEO_TOP + video_bottom = POLITICS_VIDEO_BOTTOM if profile == "politics" else VIDEO_BOTTOM + make_frame(frame, top_bar=video_top, bottom_top=video_bottom, band_color=band) bg = None if white: bg = os.path.join(_ROOT, "assets", "bg_white.png") make_solid(bg, (255, 255, 255)) pos = dict( - video_y=_ty((VIDEO_TOP + VIDEO_BOTTOM) / 2), - title_top_y=_ty(TITLE_TOP_Y), - title_main_y=_ty(TITLE_MAIN_Y), - caption_y=_ty(VIDEO_BOTTOM - CAPTION_GAP), - effect_y=_ty(VIDEO_TOP + EFFECT_GAP), - channel_y=_ty(VIDEO_BOTTOM + (CANVAS_H - VIDEO_BOTTOM) * CHANNEL_RATIO), - comment_top=COMMENT_TOP, + video_y=_ty((video_top + video_bottom) / 2), + title_top_y=_ty(190 if profile == "politics" else TITLE_TOP_Y), + title_main_y=_ty(365 if profile == "politics" else TITLE_MAIN_Y), + caption_y=_ty(video_bottom - CAPTION_GAP), + effect_y=_ty(video_top + EFFECT_GAP), + channel_y=_ty(video_bottom + (CANVAS_H - video_bottom) * CHANNEL_RATIO), + comment_top=video_bottom, ) return frame, bg, pos @@ -637,6 +643,7 @@ async def paste_draft( cards_fixed: bool = False, card_cuts: Optional[List[int]] = None, bg_white: bool = False, + content_profile: str = "entertainment", ) -> AsyncIterator[dict]: """붙여넣기 파이프라인 뒷부분: [장면분할] → 댓글 카드 → 드래프트 생성 → result. @@ -661,7 +668,7 @@ async def paste_draft( # ── draft ── yield {"type": "step", "id": "draft", "status": "start"} t = time.perf_counter() - frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white) + frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white, content_profile) # 장면분할(선택): 화면 바뀌는 지점마다 세그먼트 추가 분할(자막 시간 불변) if scene_split: @@ -696,6 +703,8 @@ async def paste_draft( channel=channel or None, video_scale=video_scale, flip_horizontal=flip_horizontal, effect_captions=eff_caps, comment_cards=cards, bg_white=bg_white, **pos, + logo_image=(os.path.join(_ROOT, "assets", "politics_factlab_logo.png") + if content_profile == "politics" else None), ) ) await _floor(t) diff --git a/capcut_agent/plan.py b/capcut_agent/plan.py index 8f49288..a760d56 100644 --- a/capcut_agent/plan.py +++ b/capcut_agent/plan.py @@ -120,7 +120,14 @@ def parse_candidates(text: str, *, src: str = "Step 1 응답") -> List[Dict]: out.append({"id": int(c.get("id") or i), "start": s, "end": e, "reason": str(c.get("reason") or "").strip(), "title_top": str(c.get("title_top") or "").strip(), - "title_main": str(c.get("title_main") or "").strip()}) + "title_main": str(c.get("title_main") or "").strip(), + # 정치 구간 JSON의 표시용 메타데이터. 일반 모드에는 빈 값이라 하위호환. + "speaker": str(c.get("speaker") or "").strip(), + "target": str(c.get("target") or "").strip(), + "issue": str(c.get("issue") or "").strip(), + "viral_type": str(c.get("viral_type") or "").strip(), + "source_channel": str(c.get("source_channel") or + c.get("channel") or "").strip()}) if not out: raise RuntimeError(f"{src}: 유효한 구간이 하나도 없습니다.") return out diff --git a/requirements.txt b/requirements.txt index 02d0308..cd7b94d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,11 +6,11 @@ # - Node.js 또는 deno (yt-dlp 유튜브 추출용 JS 런타임) # - CapCut (드래프트 열기 + 코트라 볼드체 폰트 캐시) -fastapi -uvicorn -python-multipart -pyCapCut -Pillow -pymediainfo -yt-dlp -faster-whisper # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요) +fastapi==0.115.14 +uvicorn==0.35.0 +python-multipart==0.0.20 +pyCapCut==0.0.3 +Pillow==10.4.0 +pymediainfo==7.0.1 +yt-dlp==2026.7.4 +faster-whisper==1.2.1 # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요) diff --git a/server/app.py b/server/app.py index 4d65d4b..eb75334 100644 --- a/server/app.py +++ b/server/app.py @@ -12,6 +12,7 @@ import json import os import shutil import subprocess +import tempfile import time import urllib.parse import urllib.request @@ -43,25 +44,79 @@ os.makedirs(COMMENTS_DIR, exist_ok=True) app = FastAPI(title="캡컷 에이전트") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +STATE_TTL_SECONDS = int(os.getenv("CAPCUT_STATE_TTL_SECONDS", "86400")) +UPLOAD_TTL_SECONDS = int(os.getenv("CAPCUT_UPLOAD_TTL_SECONDS", "604800")) +MAX_UPLOAD_BYTES = int(os.getenv("CAPCUT_MAX_UPLOAD_BYTES", str(4 * 1024**3))) +UPLOAD_CHUNK_BYTES = 1024 * 1024 + + +class _ExpiringStore(dict): + """기존 dict 인터페이스를 유지하면서 오래된 작업 상태를 지우는 메모리 저장소.""" + + def __init__(self, ttl: int): + super().__init__() + self.ttl = ttl + self._touched: dict[str, float] = {} + + def _purge(self) -> None: + cutoff = time.time() - self.ttl + for key, touched in list(self._touched.items()): + if touched < cutoff: + super().pop(key, None) + self._touched.pop(key, None) + + def __setitem__(self, key, value) -> None: + self._purge() + super().__setitem__(key, value) + self._touched[key] = time.time() + + def get(self, key, default=None): + self._purge() + value = super().get(key, default) + if key in self: + self._touched[key] = time.time() + return value + + def pop(self, key, default=None): + self._touched.pop(key, None) + return super().pop(key, default) + + +def _cleanup_uploads() -> None: + """참조되지 않고 보존 기간이 지난 업로드와 중단된 임시 파일을 정리한다.""" + cutoff = time.time() - UPLOAD_TTL_SECONDS + if isinstance(JOBS, _ExpiringStore): + JOBS._purge() + active = {os.path.abspath(j["path"]) for j in JOBS.values() if j.get("path")} + for entry in os.scandir(UPLOAD_DIR): + if not entry.is_file() or os.path.abspath(entry.path) in active: + continue + try: + if entry.stat().st_mtime < cutoff: + os.remove(entry.path) + except OSError: + pass + + # job_id(content hash) → {path, draft_name, title_top, title_main, channel} -JOBS: dict[str, dict] = {} +JOBS: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS) # analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음) -ANALYSES: dict[str, dict] = {} +ANALYSES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS) # 📋 붙여넣기 탭(새 흐름) — analysis_id → {"state", "places", "orig", "payload"} # /paste/stream 이 채우고 /paste/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요). -PSTATES: dict[str, dict] = {} +PSTATES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS) # ▶ 유튜브 구간 탭(새 흐름) — analysis_id → {"state"} # /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요). -YSTATES: dict[str, dict] = {} +YSTATES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS) # 🤖 자동 탭 2단계(준비) — prepare_id → {"aid", "ids"} # /auto/prepare(POST) 가 채우고 /auto/prepare/{pid}(SSE) 가 꺼내 쓴다. # 준비된 개별 편집안의 다운로드·받아쓰기 상태는 PSTATES[f"{aid}:{id}"] 에 담긴다 # (📋 붙여넣기 탭과 같은 저장소를 공유 — /auto/build 가 그 값으로 paste_draft 만 돌린다). -PREPARES: dict[str, dict] = {} +PREPARES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS) _DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드") @@ -137,6 +192,89 @@ def _parse_ranges(raw: str) -> list[tuple[str, str]]: return out +def _highlight_card_count(total: float) -> int: + return max(1, int(total // 3)) + + +def _cuts_json(cuts) -> list[dict]: + return [{"start": s, "end": e, "bottom": b, "effect": f} + for s, e, b, f in cuts] + + +def _whole_highlight(candidate: dict, url: str, *, profile: str = "entertainment") -> dict: + """후보 구간 전체를 컷 하나로 쓰는 자동 편집안으로 변환한다.""" + total = candidate["end"] - candidate["start"] + return { + "id": candidate["id"], "start": candidate["start"], "end": candidate["end"], + "reason": candidate["reason"], + "paste": {"url": url, + "title_top": candidate.get("title_top") or "", + "title_main": candidate.get("title_main") or "", + # 정치 모드는 비워 둬야 paste_analyze가 URL의 실제 uploader를 가져온다. + "channel": "", + "cuts": [{"start": candidate["start"], "end": candidate["end"], + "bottom": "", "effect": ""}]}, + "titles": [], "editable_title": True, + "total": round(total, 1), + "need": 0 if profile == "politics" else _highlight_card_count(total), + "content_profile": profile, + "issue": candidate.get("issue") or "", + "viral_type": candidate.get("viral_type") or "", + } + + +async def _plan_highlight(url: str, index: int, candidate: dict) -> dict: + """Gemini 할당량 충돌 시 요청별 시차를 두고 편집안을 최대 세 번 시도한다.""" + for attempt in range(1, 4): + try: + return await asyncio.to_thread( + autoplan.edit_plan, url, candidate["start"], candidate["end"]) + except GeminiQuotaError: + if attempt == 3: + raise + await asyncio.sleep(15 * attempt + index * 5) + raise RuntimeError("편집안 생성 재시도 횟수를 초과했습니다.") + + +class _UploadTooLarge(Exception): + pass + + +async def _save_upload(file: UploadFile) -> tuple[str, str]: + """업로드를 메모리에 적재하지 않고 저장하고 (content hash, 경로)를 반환한다.""" + _cleanup_uploads() + digest = hashlib.sha1() + size = 0 + tmp_path = "" + try: + with tempfile.NamedTemporaryFile(dir=UPLOAD_DIR, prefix="upload-", suffix=".part", + delete=False) as tmp: + tmp_path = tmp.name + while chunk := await file.read(UPLOAD_CHUNK_BYTES): + size += len(chunk) + if size > MAX_UPLOAD_BYTES: + raise _UploadTooLarge + digest.update(chunk) + tmp.write(chunk) + + content_hash = digest.hexdigest()[:12] + ext = os.path.splitext(file.filename or "")[1].lower() or ".mp4" + path = os.path.join(UPLOAD_DIR, content_hash + ext) + if os.path.exists(path): + os.remove(tmp_path) + os.utime(path, None) + else: + os.replace(tmp_path, path) + return content_hash, path + except Exception: + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + raise + + @app.post("/upload") async def upload( file: UploadFile = File(...), @@ -150,14 +288,11 @@ async def upload( bg_white: str = Form(""), remove_silence: str = Form("1"), ) -> JSONResponse: - data = await file.read() - # content hash → 같은 영상 재업로드 시 캐시/멱등 (mtime 아님) - h = hashlib.sha1(data).hexdigest()[:12] - ext = os.path.splitext(file.filename or "")[1].lower() or ".mp4" - path = os.path.join(UPLOAD_DIR, h + ext) - if not os.path.exists(path): - with open(path, "wb") as f: - f.write(data) + try: + h, path = await _save_upload(file) + except _UploadTooLarge: + gib = MAX_UPLOAD_BYTES / 1024**3 + return JSONResponse({"error": f"업로드 파일이 제한({gib:g}GB)을 넘습니다."}, 413) base = os.path.splitext(os.path.basename(file.filename or "video"))[0] safe = "".join(c for c in base if c.isalnum() or c in (" ", "_", "-")).strip() or "video" JOBS[h] = { @@ -222,6 +357,7 @@ async def stream(job_id: str) -> StreamingResponse: cards_fixed=job.get("cards_fixed", False), card_cuts=job.get("card_cuts") or None, bg_white=job.get("bg_white", False), + content_profile=job.get("content_profile", "entertainment"), ) elif job.get("bg_state"): # ▶ 유튜브 구간 탭(새 흐름) — 이미 분석된 상태로 드래프트만 # paste_state 와 같은 이유로 draft 단독 manifest 를 여기서 새로 낸다. @@ -482,13 +618,14 @@ async def auto_analyze(url: str = Form(""), mode: str = Form("full"), mode: full = Step1+Step3 (AI 컷편집, 기본) whole = Step1만 — 구간 5개를 통짜로(컷 편집 없음) paste = 오팔 JSON 여러 개 붙여넣기 — Gemini 안 씀 + politics = 정치 구간 JSON — 댓글·검토 없이 정치 레이아웃 생성 """ - mode = mode if mode in ("full", "whole", "wpaste", "paste") else "full" + mode = mode if mode in ("full", "whole", "wpaste", "paste", "politics") else "full" u = url.strip() if mode == "paste": if not data.strip(): return JSONResponse({"error": "오팔 JSON을 붙여넣으세요."}, 400) - elif mode == "wpaste": + elif mode in ("wpaste", "politics"): # 구간 JSON 붙여넣기 — Gemini 안 씀. URL은 JSON에 없으므로 입력칸이 필수. if not data.strip(): return JSONResponse({"error": "구간 JSON을 붙여넣으세요."}, 400) @@ -526,32 +663,6 @@ async def auto_stream(aid: str) -> StreamingResponse: mode = a.get("mode", "full") highlights: list[dict] = [] - def _need(total: float) -> int: - return max(1, int(total // 3)) - - def _cuts_json(cuts) -> list[dict]: - return [{"start": s, "end": e, "bottom": b, "effect": f} - for s, e, b, f in cuts] - - def _whole_hl(c: dict) -> dict: - """구간 통짜 하이라이트 — 컷 편집 없이 구간 전체가 컷 1개. - 제목은 `editable_title` 로 UI에서 직접 입력받되, 구간 JSON에 - title_top/title_main 이 있으면 그 값을 입력칸에 미리 채운다. - whole(Gemini Step1) 과 wpaste(구간 JSON 붙여넣기) 가 공유.""" - total = c["end"] - c["start"] - return { - "id": c["id"], "start": c["start"], "end": c["end"], - "reason": c["reason"], - "paste": {"url": url, - "title_top": c.get("title_top") or "", - "title_main": c.get("title_main") or "", - "channel": "", - "cuts": [{"start": c["start"], "end": c["end"], - "bottom": "", "effect": ""}]}, - "titles": [], "editable_title": True, - "total": round(total, 1), "need": _need(total), - } - if mode == "paste": # ── 오팔 JSON 여러 개 — Gemini 안 씀 ── yield _sse({"type": "manifest", "steps": [ @@ -613,7 +724,8 @@ async def auto_stream(aid: str) -> StreamingResponse: "paste": {"url": p["url"], "title_top": p["title_top"], "title_main": p["title_main"], "channel": p["channel"], "cuts": _cuts_json(p["cuts"])}, - "titles": titles, "total": round(total, 1), "need": _need(total), + "titles": titles, "total": round(total, 1), + "need": _highlight_card_count(total), }) if not highlights: yield _sse({"type": "error", @@ -622,7 +734,7 @@ async def auto_stream(aid: str) -> StreamingResponse: url = best_url yield _sse({"type": "step", "id": "parse", "status": "done", "detail": f"{len(highlights)}개 편집안"}) - elif mode == "wpaste": + elif mode in ("wpaste", "politics"): # ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ── yield _sse({"type": "manifest", "steps": [ {"id": "parse", "label": "구간 JSON 파싱"}, @@ -633,7 +745,8 @@ async def auto_stream(aid: str) -> StreamingResponse: except Exception as exc: # noqa: BLE001 yield _sse({"type": "error", "message": str(exc)}) return - highlights.extend(_whole_hl(c) for c in cands) + profile = "politics" if mode == "politics" else "entertainment" + highlights.extend(_whole_highlight(c, url, profile=profile) for c in cands) yield _sse({"type": "step", "id": "parse", "status": "done", "detail": f"{len(highlights)}개 구간"}) else: @@ -655,23 +768,12 @@ async def auto_stream(aid: str) -> StreamingResponse: if mode == "whole": # ── 구간 통짜 — 컷 편집 없이 구간 전체가 컷 1개 ── - highlights.extend(_whole_hl(c) for c in cands) + highlights.extend(_whole_highlight(c, url) for c in cands) else: # ── Step 3 (동시) ── yield _sse({"type": "step", "id": "step3", "status": "start"}) - async def _plan_one(i: int, c: dict): - # 429는 시차를 두고 재시도(동시 재충돌 방지: 시도×15s + 구간×5s) - for attempt in range(1, 4): - try: - return await asyncio.to_thread( - autoplan.edit_plan, url, c["start"], c["end"]) - except GeminiQuotaError: - if attempt == 3: - raise - await asyncio.sleep(15 * attempt + i * 5) - - plan_tasks = [asyncio.create_task(_plan_one(i, c)) + plan_tasks = [asyncio.create_task(_plan_highlight(url, i, c)) for i, c in enumerate(cands)] for c, t in zip(cands, plan_tasks): hl = {"id": c["id"], "start": c["start"], "end": c["end"], @@ -687,7 +789,7 @@ async def auto_stream(aid: str) -> StreamingResponse: "cuts": _cuts_json(p["cuts"])}, "titles": r["titles"], "total": round(total, 1), - "need": _need(total), + "need": _highlight_card_count(total), }) if r["time_note"]: yield _sse({"type": "log", @@ -751,7 +853,9 @@ async def auto_prepare(aid: str = Form(...), ids: str = Form(...), return JSONResponse({"error": "준비할 ID 목록이 올바르지 않습니다."}, 400) rs = _truthy(remove_silence) pid = hashlib.sha1((aid + "|" + ids + "|rs" + ("1" if rs else "0")).encode()).hexdigest()[:12] - PREPARES[pid] = {"aid": aid, "ids": id_list, "remove_silence": rs} + PREPARES[pid] = {"aid": aid, "ids": id_list, "remove_silence": rs, + "content_profile": "politics" if a.get("mode") == "politics" + else "entertainment"} return JSONResponse({"prepare_id": pid}) @@ -780,23 +884,25 @@ async def auto_prepare_stream(pid: str) -> StreamingResponse: yield _sse({"type": "error", "message": "준비할 편집안이 없습니다."}) return url = a.get("url", "") + politics = p.get("content_profile") == "politics" warnings: list[str] = [] - yield _sse({"type": "manifest", "steps": [ - {"id": "comments", "label": "댓글 수집 (h-lab)"}, - {"id": "prepare", "label": "ID별 순차 준비 (다운로드·받아쓰기)"}, - ]}) + steps = [{"id": "prepare", "label": "ID별 순차 준비 (다운로드·받아쓰기)"}] + if not politics: + steps.insert(0, {"id": "comments", "label": "댓글 수집 (h-lab)"}) + yield _sse({"type": "manifest", "steps": steps}) - yield _sse({"type": "step", "id": "comments", "status": "start"}) comments: list[dict] = [] - try: - comments = await asyncio.to_thread(hlab.fetch_comments, url) - 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": "실패(생략)"}) + if not politics: + yield _sse({"type": "step", "id": "comments", "status": "start"}) + try: + comments = await asyncio.to_thread(hlab.fetch_comments, url) + 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": "prepare", "status": "start"}) highlights_out: list[dict] = [] @@ -835,24 +941,33 @@ async def auto_prepare_stream(pid: str) -> StreamingResponse: warnings.append(f"ID {hid} 준비 실패 — 분석 상태를 만들지 못했습니다") continue + if politics: + # paste_analyze가 다운로드 결과의 uploader를 "@채널명"으로 채운다. + # 하단 표시는 계정 멘션이 아니라 출처 표기이므로 접두사를 바꾼다. + actual_channel = str(state.get("channel") or "").strip().lstrip("@").strip() + state["channel"] = (f"출처 · {actual_channel}" if actual_channel else "출처 · 채널명 확인 필요") + # ⚠ 좌표계 둘: places = 압축 타임라인(카드·자막 추출용), # orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다. places = state["card_places"] orig = [(s, e) for s, e, _, _ in state["cuts"]] all_ranges.extend(orig) - try: - cuts, need, ai_failed = await asyncio.to_thread( - recommend.cuts_from_state, places, orig, state["bottom_caps"], comments) - if ai_failed: - warnings.append(f"ID {hid} AI 추천 실패(Gemini 응답 없음) — " - "분:초·자막단어·좋아요로 배정했습니다") - except Exception as exc: # noqa: BLE001 - cuts, need = None, max(1, int(state["timeline_dur"] // 3)) - warnings.append(f"ID {hid} 컷별 추천 실패 — 컷 정보 없이 진행합니다 " - f"({type(exc).__name__}: {exc})") + if politics: + cuts, need = None, 0 + else: + try: + cuts, need, ai_failed = await asyncio.to_thread( + recommend.cuts_from_state, places, orig, state["bottom_caps"], comments) + if ai_failed: + warnings.append(f"ID {hid} AI 추천 실패(Gemini 응답 없음) — " + "분:초·자막단어·좋아요로 배정했습니다") + except Exception as exc: # noqa: BLE001 + cuts, need = None, max(1, int(state["timeline_dur"] // 3)) + warnings.append(f"ID {hid} 컷별 추천 실패 — 컷 정보 없이 진행합니다 " + f"({type(exc).__name__}: {exc})") PSTATES[f"{aid}:{hid}"] = {"state": state, "places": places, "orig": orig, - "payload": payload} + "payload": payload, "content_profile": p["content_profile"]} matched = hlab.match_ranges(comments, orig) if comments else [] highlights_out.append({ "id": hid, "cuts": cuts, "need": need, @@ -951,8 +1066,20 @@ async def auto_build( if not st: return JSONResponse({"error": "준비 결과가 만료됐습니다. 다시 준비해 주세요."}, 404) state = st["state"] - state["title_top"] = title_top - state["title_main"] = title_main + # 정치 모드는 검토 화면이 없어 브라우저 상태가 초기화돼 빈 제목이 올 수 있다. + # 이 경우 준비 단계에 보관한 원래 JSON 제목을 복구해 자리표시자가 들어가지 않게 한다. + original = st.get("payload") or {} + state["title_top"] = title_top.strip() or str(original.get("title_top") or "").strip() + state["title_main"] = title_main.strip() or str(original.get("title_main") or "").strip() + # 자동 탭에서 만드는 모든 드래프트 이름은 화면 제목과 동일하게 맞춘다. + # Windows/CapCut 폴더명에 안전한 문자만 남기고 "title_top title_main" 형식을 유지한다. + display_name = " ".join(x for x in (state["title_top"], state["title_main"]) if x).strip() + safe_name = "".join(c for c in display_name + if c.isalnum() or c in (" ", "_", "-", ".")).strip()[:80] + if safe_name: + state["draft_name"] = safe_name + if st.get("content_profile") == "politics": + state["channel"] = str(original.get("channel") or state.get("channel") or "").strip() # asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로 # 되돌린다(/paste/build 와 동일 규칙 — state["bottom_caps"]는 /auto/prepare 가 @@ -974,7 +1101,8 @@ async def auto_build( cut_map = [] sig = (key + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|" - + bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards))) + + bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + + st.get("content_profile", "entertainment") + "|" + str(len(cards))) h = hashlib.sha1(sig.encode()).hexdigest()[:12] cdir = comments_dir.strip() if cards: @@ -991,6 +1119,7 @@ async def auto_build( "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), + "content_profile": st.get("content_profile", "entertainment"), } return JSONResponse({"job_id": h}) diff --git a/server/static/auto.js b/server/static/auto.js index fae237e..76715e3 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -534,6 +534,7 @@ const MODE_HELP={ full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다. 받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만들고, 제목은 JSON의 title_top·title_main을 그대로 씁니다. 1차 검토 없이 바로 다운로드·받아쓰기·댓글 매칭까지 진행되고 댓글 선택 화면으로 갑니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", + politics:"정치 구간 JSON을 그대로 사용합니다. 댓글 수집과 검토를 생략하고, 125% 전면 영상+확대 배경의 정치 레이아웃으로 만든 뒤 CapCut을 자동 실행합니다.", paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", }; const PASTE_UI={ @@ -543,10 +544,14 @@ const PASTE_UI={ wpaste:{label:"구간 JSON 붙여넣기 (candidates 배열 — 구간 5개 · title_top/title_main 선택)", ph:'{\n "candidates": [\n {"id": 1, "start_time": "14:28", "end_time": "16:15", "reason": "구간 선정 이유",\n "title_top": "상단 제목 (선택)", "title_main": "메인 제목 (선택)"},\n … (총 5개)\n ]\n}', note:"(필수 — 구간 JSON에는 URL이 없습니다)"}, + politics:{label:"정치 구간 JSON 붙여넣기 (speaker·target·title_top·title_main 사용)", + ph:'{\n "candidates": [\n {"id": 1, "start_time": "04:27", "end_time": "06:21",\n "speaker": "발언자", "target": "관련 기관",\n "title_top": "상단 제목", "title_main": "메인 제목"}\n ]\n}', + note:"(필수 — 구간 JSON에는 URL이 없습니다)"}, }; function applyMode(){ const m=curMode(); const p=PASTE_UI[m]; + const politics=m==="politics"; $("#apasteField").style.display=p?"block":"none"; if(p){ $("#apasteLabel").textContent=p.label; @@ -554,8 +559,14 @@ function applyMode(){ } $("#autoUrlNote").textContent=p?p.note:""; $("#autoModeHelp").textContent=MODE_HELP[m]; - $("#autoGo").textContent=(p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)")+ - (autoRunOn()?" → 영상까지 자동":""); + const runField=$("#autoRunField"); if(runField) runField.style.display=politics?"none":"block"; + const silenceNote=$("#autoSilenceNote"); + if(silenceNote) silenceNote.textContent=politics + ?"받아쓰기(Whisper)는 적용합니다. 체크를 끄면 원본 구간 길이를 그대로 유지합니다." + :"받아쓰기(Whisper)·댓글 매칭은 항상 돌지만, 무음 제거는 꺼도 매칭이 어긋나지 않습니다."; + $("#autoGo").textContent=politics?"정치 숏폼 생성 → CapCut 자동 실행": + ((p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)")+ + (autoRunOn()?" → 영상까지 자동":"")); } /* "끝까지 진행" — 검토 화면 두 곳(1차 제목 선택 · 2차 댓글 선택)에서 멈추지 않고 기본값·추천 그대로 다음 단계로 넘어간다. 화면 편의 기능이라 서버는 이 상태를 모른다. */ @@ -566,7 +577,7 @@ async function analyze(){ const m=curMode(); const url=$("#autoUrl").value.trim(); if(m!=="paste"&&!url){ - alert(m==="wpaste"?"유튜브 URL을 입력하세요. (구간 JSON에는 URL이 없습니다)" + alert((m==="wpaste"||m==="politics")?"유튜브 URL을 입력하세요. (구간 JSON에는 URL이 없습니다)" :"유튜브 URL을 입력하세요.");return;} if(PASTE_UI[m]&&!$("#apaste").value.trim()){ alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;} @@ -600,7 +611,13 @@ async function analyze(){ // 구간 JSON 모드: 제목이 JSON에 이미 있어 1차 검토(제목 선택)가 무의미 → // 유효 구간이 있으면(autoPrepGo 표시 = ok>0) 바로 준비(다운로드·받아쓰기·댓글 매칭) 시작. // "끝까지 진행"이 켜져 있으면 나머지 모드도 제목 기본값(첫 후보)으로 그냥 넘어간다. - if((m==="wpaste"||autoRunOn())&&$("#autoPrepGo").style.display==="block"){ + if(m==="politics"){ + TITLE_PICKS={}; + for(const h of A.highlights) if(!h.error) + TITLE_PICKS[h.id]={top:h.paste.title_top||"",main:h.paste.title_main||""}; + alog("정치 모드 — 댓글과 검토를 생략하고 바로 생성 준비를 시작합니다."); + prepareAll(); + }else if((m==="wpaste"||autoRunOn())&&$("#autoPrepGo").style.display==="block"){ alog(m==="wpaste"?"구간 JSON 모드 — 제목이 JSON에 있으므로 바로 준비를 시작합니다." :"끝까지 진행 — 제목은 기본 후보로 두고 바로 준비를 시작합니다."); prepareAll(); @@ -707,7 +724,7 @@ async function prepareAll(){ if(!A) return; const ids=liveIds(); if(!ids.length){alert("준비할 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;} - TITLE_PICKS=collectTitlePicks(); + if(curMode()!=="politics") TITLE_PICKS=collectTitlePicks(); const btn=$("#autoPrepGo"); btn.disabled=true;btn.textContent="준비 중…"; $("#autoPrepSteps").innerHTML="";$("#autoPrepLog").innerHTML=""; @@ -782,6 +799,18 @@ function clearOtherPanels(exceptId){ function onPrepareResult(ev){ P=ev;byIdx={};sel={};selCut={};curId=null; clearOtherPanels("auto"); + if(curMode()==="politics"){ + (ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w)); + $("#autoPickReview").innerHTML=""; + $("#autoReview").innerHTML=""; + if(!ev.highlights||!ev.highlights.length){ + prepLog("⚠️ 준비된 정치 편집안이 없습니다."); + return; + } + prepLog("정치 모드 — 준비 완료, 댓글 카드 없이 드래프트를 생성합니다."); + setTimeout(()=>buildPolitics(ev.highlights),0); + return; + } (ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;}); (ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w)); const R=$("#autoReview");R.innerHTML=""; @@ -844,6 +873,31 @@ function onPrepareResult(ev){ } } +async function buildPolitics(hls){ + boardInit(hls); + let ok=0,fail=0; + for(const hl of hls){ + boardSet(hl.id,"🔄 진행","정치 레이아웃 생성 중…","active"); + try{ + const pick=TITLE_PICKS[hl.id]||{top:"",main:""}; + const fd=new FormData(); + fd.append("aid",AUTO_AID); fd.append("id",String(hl.id)); + fd.append("title_top",pick.top||""); fd.append("title_main",pick.main||""); + fd.append("video_scale","160"); + fd.append("flip",$("#flip").checked?"1":""); + fd.append("scene",$("#scene").checked?"1":""); + fd.append("bg_white",""); fd.append("asr_bottom","1"); + const res=await(await fetch("/auto/build",{method:"POST",body:fd})).json(); + if(res.error) throw new Error(res.error); + const r=await streamJob(res.job_id,hl.id); + boardSet(hl.id,"✅ 완료",(r&&r.draft_name)||"",""); ok++; + }catch(e){boardSet(hl.id,"❌ 실패",String(e.message||e),"err");fail++;} + } + const s=$("#autoSummary");s.style.display="block"; + s.textContent=ok+"개 성공"+(fail?", "+fail+"개 실패":"")+" — 정치 레이아웃 생성 완료"; + if(ok) fetch("/open-capcut",{method:"POST"}); +} + /* ── 캡처 + 순차 빌드 ── */ async function captureCard(wrap){ const img=wrap.querySelector(".cc-avatar"); diff --git a/server/static/index.html b/server/static/index.html index 26a6a79..d759594 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -444,12 +444,14 @@