diff --git a/README.md b/README.md index 5b26cab..dcc1338 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고, |------|------| | 수정한 게 반영 안 됨 | 검은 창 닫고 **.bat 재시작** | | `python not found` | Python 재설치 시 PATH 체크 후 재부팅 | -| 유튜브 `Video unavailable` | 영상이 실제 공개인지 확인 → `python -m pip install -U yt-dlp` | -| 다운로드가 자꾸 깨짐 | `python -m pip install -U yt-dlp` (유튜브가 가끔 바뀜) | +| 유튜브 `Video unavailable` | 영상이 실제 공개인지 확인 → `python -m pip install -U --pre "yt-dlp[default]"` | +| 다운로드가 자꾸 깨짐 / `HTTP 403` | `python -m pip install -U --pre "yt-dlp[default]"` (nightly + EJS 구성요소 업데이트) | | 자막이 밀림 | 붙여넣기 탭은 컷·자막을 그대로 쓰므로 안 밀림. 파일/유튜브 탭은 Whisper 타이밍 사용 | | 자막 `\n` 이 글자로 박힘 | .bat 재시작하면 해결(앱이 `\n`·`\\n` 자동 분할) | | 폰트가 다르게 나옴 | CapCut 에서 코트라 볼드체·제주명조체를 한 번씩 사용해 캐시 생성 | diff --git a/SETUP.md b/SETUP.md index 9663bc9..9d37164 100644 --- a/SETUP.md +++ b/SETUP.md @@ -135,7 +135,7 @@ python-multipart==0.0.20 pyCapCut==0.0.3 # import 이름은 pycapcut Pillow==10.4.0 pymediainfo==7.0.1 -yt-dlp==2026.7.4 +yt-dlp[default]>=2026.8.17.73947.dev0 faster-whisper==1.2.1 # ── faster-whisper / fastapi 전이 의존성(자동 설치되지만 버전 고정용) ── @@ -150,7 +150,7 @@ pydantic==2.11.7 ``` > **참고** -> - `yt-dlp`는 pip로 깔면 `yt-dlp` **콘솔 스크립트가 PATH에 생겨** 코드가 subprocess로 호출합니다. (유튜브는 자주 막히니 **주기적으로 `python -m pip install -U yt-dlp` 업데이트 권장** — 버전 고정하지 말 것.) +> - `yt-dlp`는 pip로 깔면 `yt-dlp` **콘솔 스크립트가 PATH에 생겨** 코드가 subprocess로 호출합니다. (유튜브는 자주 바뀌므로 문제가 생기면 **`python -m pip install -U --pre "yt-dlp[default]"`** 로 nightly와 EJS 구성요소까지 업데이트하세요.) > - SSE(진행상황 스트림)는 FastAPI 내장 `StreamingResponse` 사용 → `sse-starlette` **불필요**. > - Gemini 교정은 표준 라이브러리 `urllib`로 REST 직접 호출 → `google-generativeai` **패키지 불필요**. > - `pymediainfo`는 내부적으로 **MediaInfo DLL**을 씀. 대개 wheel에 포함되나, 안 되면 https://mediaarea.net/en/MediaInfo 의 DLL을 PATH에 두면 됨. @@ -272,7 +272,7 @@ yt-dlp --version | `ffmpeg`/`ffprobe` not found | PATH 미등록 | ffmpeg `bin`을 시스템 PATH에 추가, 창 새로 열기 | | 유튜브 다운로드가 ffmpeg 크래시(exit 3436169992) | JS 런타임 없음 | Node.js 또는 deno 설치 | | "Video unavailable" | 영상 자체 없음/지역제한 | `curl "https://www.youtube.com/oembed?url=&format=json"` 404면 영상 문제 | -| 유튜브가 갑자기 다 실패 | yt-dlp 구버전 | `python -m pip install -U yt-dlp` | +| 유튜브가 갑자기 다 실패 / `HTTP 403` | yt-dlp 구버전·YouTube 클라이언트 정책 변경 | `python -m pip install -U --pre "yt-dlp[default]"` | | 자막이 기본 폰트로 나옴 | 코트라 볼드체 캐시 없음 | §6 — CapCut에서 1회 사용 or font.ttf 복사 (없어도 동작은 함) | | ASR 첫 실행이 매우 느림/멈춘 듯 | Whisper medium(1.5GB) 다운로드 중 | 최초 1회. 인터넷 확인, 기다리기 | | 자막 교정이 안 됨 | Gemini 키 없음/429 | 선택 기능. 없으면 Whisper 원문 사용(정상) | @@ -306,7 +306,7 @@ python-multipart 0.0.20 pyCapCut 0.0.3 (import pycapcut) Pillow 10.4.0 pymediainfo 7.0.1 -yt-dlp 2026.7.4 (최신 유지 권장) +yt-dlp 2026.8.17 nightly 이상 (`yt-dlp[default]`, 최신 유지 권장) faster-whisper 1.2.1 ├ av 16.0.1 ├ ctranslate2 4.6.2 diff --git a/assets/intro_dingdong.mp3 b/assets/intro_dingdong.mp3 new file mode 100644 index 0000000..62b204e Binary files /dev/null and b/assets/intro_dingdong.mp3 differ diff --git a/capcut_agent/draft.py b/capcut_agent/draft.py index b248231..26517bb 100644 --- a/capcut_agent/draft.py +++ b/capcut_agent/draft.py @@ -22,6 +22,9 @@ Clip = Tuple[float, float, Optional[str]] DEFAULT_DRAFT_ROOT = os.path.join( os.environ["LOCALAPPDATA"], "CapCut", "User Data", "Projects", "com.lveditor.draft" ) +INTRO_SFX_PATH = os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "assets", "intro_dingdong.mp3" +)) def _us(seconds: float) -> int: @@ -230,6 +233,8 @@ def build_bg_template_draft( script.add_track(p.TrackType.text, "channel", relative_index=4) if effect_captions: script.add_track(p.TrackType.text, "effect", relative_index=5) + if os.path.isfile(INTRO_SFX_PATH): + script.add_track(p.TrackType.audio, "intro_sfx") # 영상 소재 로드 + 실제 소재 길이(µs)로 컷 끝 클램프 # ffprobe 가 보고한 길이가 CapCut 이 보는 소재 길이보다 살짝 길 때(마지막 프레임) @@ -247,6 +252,17 @@ def build_bg_template_draft( # 타임라인 길이 = 보존 비디오 구간 합(클램프 반영) total_us = sum(e - s for s, e in clips_us) + # 완성 영상 시작(0초)에 인트로 효과음을 한 번 재생한다. + # 극단적으로 짧은 영상은 타임라인 끝까지만 사용해 소재 범위 초과를 막는다. + if os.path.isfile(INTRO_SFX_PATH) and total_us > 0: + intro_sfx = p.AudioMaterial(INTRO_SFX_PATH) + intro_dur = min(total_us, intro_sfx.duration) + if intro_dur > 0: + script.add_segment( + p.AudioSegment(intro_sfx, p.Timerange(0, intro_dur)), + "intro_sfx", + ) + # 흰 배경(맨 아래, 전체 길이) — 선택 if has_bg: bg_mat = p.VideoMaterial(bg_image_path) @@ -278,7 +294,8 @@ def build_bg_template_draft( 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: + caption_end_us = 0 + for ts, te, text in sorted(captions, key=lambda x: (x[0], x[1])): if _us(te) - _us(ts) <= 0 or not text: continue lines = _cap_lines(text) @@ -287,14 +304,19 @@ def build_bg_template_draft( # 구간을 줄 수만큼 균등 분할 → i번째 줄이 i번째 시간대에 표시(같은 위치) a = ts + (te - ts) * i / n b = ts + (te - ts) * (i + 1) / n - seg = _us(b) - _us(a) + # Whisper 구간이 수 ms 겹치는 경우 pycapcut은 같은 텍스트 트랙의 + # SegmentOverlap으로 드래프트 전체를 중단한다. 앞 자막 끝으로 클램프한다. + a_us = max(_us(a), caption_end_us) + b_us = min(_us(b), total_us) + seg = b_us - a_us if seg <= 0: continue script.add_segment(p.TextSegment( - ln, p.Timerange(_us(a), seg), + ln, p.Timerange(a_us, seg), style=cap_style, border=cap_border, clip_settings=p.ClipSettings(transform_y=caption_y), ), "caption") + caption_end_us = b_us # 효과 자막(중앙): 하단 자막 '바로 위'. 주황 볼드 + 검은 외곽선(배경박스 없음). if effect_captions: @@ -302,15 +324,19 @@ def build_bg_template_draft( eff_style = p.TextStyle(size=13.0, bold=False, color=(13/255, 255/255, 99/255), align=1) # #0dff63 녹색 eff_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0) - for ts, te, text in effect_captions: - dur = _us(te) - _us(ts) + effect_end_us = 0 + for ts, te, text in sorted(effect_captions, key=lambda x: (x[0], x[1])): + start_us = max(_us(ts), effect_end_us) + end_us = min(_us(te), total_us) + dur = end_us - start_us if dur <= 0 or not text: continue script.add_segment(p.TextSegment( - text, p.Timerange(_us(ts), dur), + text, p.Timerange(start_us, dur), style=eff_style, border=eff_border, clip_settings=p.ClipSettings(transform_y=eff_y), ), "effect") + effect_end_us = end_us # 프레임(상하 검은 띠 + 투명 가운데) — 영상 위, 전체 길이 frame_mat = p.VideoMaterial(frame_image_path) @@ -330,8 +356,11 @@ def build_bg_template_draft( # CapCut scale 1.0 = contain. 댓글 카드는 캔버스(9:16)보다 가로로 넓으므로 # 가로가 먼저 맞아 표시 높이 = 1080 × (h/w) × scale. if comment_cards: - for ts, te, img in comment_cards: - dur = _us(te) - _us(ts) + comment_end_us = 0 + for ts, te, img in sorted(comment_cards, key=lambda x: (x[0], x[1])): + start_us = max(_us(ts), comment_end_us) + end_us = min(_us(te), total_us) + dur = end_us - start_us if dur <= 0 or not img or not os.path.isfile(img): continue eff = COMMENT_SCALE @@ -346,10 +375,11 @@ def build_bg_template_draft( else: cy = comment_y if comment_y is not None else round(-1162/1920, 4) script.add_segment(p.VideoSegment( - p.VideoMaterial(img), p.Timerange(_us(ts), dur), + p.VideoMaterial(img), p.Timerange(start_us, dur), clip_settings=p.ClipSettings(scale_x=eff, scale_y=eff, transform_x=0.0, transform_y=cy), ), "comment") + comment_end_us = end_us full = p.Timerange(0, total_us) # 제목 두 줄: 윗줄 = 주황 포인트색, 아랫줄 = 흰색. 둘 다 크게+볼드+검은 외곽선. diff --git a/capcut_agent/youtube.py b/capcut_agent/youtube.py index 6c489a6..2318b5f 100644 --- a/capcut_agent/youtube.py +++ b/capcut_agent/youtube.py @@ -30,6 +30,21 @@ TIME_RE = re.compile(r"^(?:\d{1,2}:)?\d{1,2}:[0-5]\d$") TIMEOUT_SEC = 1800 +def _yt_dlp_error(proc: subprocess.CompletedProcess) -> str: + """Keep the useful upstream error instead of only ffmpeg's final exit line.""" + output = (proc.stderr or proc.stdout or "").strip() + if not output: + return f"exit code {proc.returncode}" + lines = output.splitlines() + diagnostic = [ + line for line in lines + if ("ERROR:" in line or "HTTP error" in line + or "Error opening input" in line or "Forbidden" in line) + ] + selected = diagnostic[-12:] if diagnostic else lines[-20:] + return "\n".join(selected)[-4000:] + + def valid_time(t: str) -> bool: return bool(TIME_RE.match(t.strip())) @@ -171,7 +186,7 @@ def cut_youtube(url: str, start: str, end: str, out_dir: str) -> Tuple[str, str, proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=TIMEOUT_SEC, env=env) if proc.returncode != 0: - tail = (proc.stderr or proc.stdout or "").strip()[-800:] + tail = _yt_dlp_error(proc) raise RuntimeError(f"yt-dlp 실패:\n{tail}") # stdout 에서 채널명 파싱 (경로는 폴더 스캔으로 확정 — 인코딩 의존 제거) @@ -349,7 +364,7 @@ def _dl_section(url: str, start_sec: float, end_sec: float, out_dir: str, if proc.returncode != 0: proc = _run([]) if proc.returncode != 0: - tail = (proc.stderr or proc.stdout or "").strip()[-800:] + tail = _yt_dlp_error(proc) raise RuntimeError(f"yt-dlp 실패:\n{tail}") channel = "" diff --git a/requirements.txt b/requirements.txt index cd7b94d..25dd688 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,5 +12,5 @@ python-multipart==0.0.20 pyCapCut==0.0.3 Pillow==10.4.0 pymediainfo==7.0.1 -yt-dlp==2026.7.4 +yt-dlp[default]>=2026.8.17.73947.dev0 faster-whisper==1.2.1 # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요) diff --git a/server/static/index.html b/server/static/index.html index d759594..d53930e 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -362,14 +362,14 @@
- + - +
-
+