fix: stabilize YouTube cuts and draft generation
This commit is contained in:
parent
76714e4bde
commit
4927c61ac5
@ -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 에서 코트라 볼드체·제주명조체를 한 번씩 사용해 캐시 생성 |
|
||||
|
||||
8
SETUP.md
8
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=<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
|
||||
|
||||
BIN
assets/intro_dingdong.mp3
Normal file
BIN
assets/intro_dingdong.mp3
Normal file
Binary file not shown.
@ -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)
|
||||
# 제목 두 줄: 윗줄 = 주황 포인트색, 아랫줄 = 흰색. 둘 다 크게+볼드+검은 외곽선.
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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 # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요)
|
||||
|
||||
@ -362,14 +362,14 @@
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" id="tab-file">📁 파일</button>
|
||||
<button class="tab" id="tab-file">📁 파일</button>
|
||||
<button class="tab" id="tab-yt">▶ 유튜브 구간</button>
|
||||
<button class="tab" id="tab-paste">📋 붙여넣기</button>
|
||||
<button class="tab" id="tab-auto">🤖 자동</button>
|
||||
<button class="tab active" id="tab-auto">🤖 자동</button>
|
||||
</div>
|
||||
|
||||
<!-- 파일 모드 -->
|
||||
<div id="panel-file">
|
||||
<div id="panel-file" style="display:none;">
|
||||
<div class="helprow"><button type="button" class="helpbtn" data-help="file">? 사용법</button></div>
|
||||
<div id="drop">
|
||||
<div class="big">영상을 여기에 드롭하거나 클릭</div>
|
||||
@ -439,7 +439,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 자동(Gemini) 모드 -->
|
||||
<div id="panel-auto" style="display:none;">
|
||||
<div id="panel-auto">
|
||||
<div class="card" style="padding:18px;">
|
||||
<div class="field">
|
||||
<label>방식</label>
|
||||
@ -709,7 +709,7 @@ document.querySelectorAll(".helpbtn").forEach(b=>b.addEventListener("click",()=>
|
||||
$("#helpClose").addEventListener("click",()=>overlay.style.display="none");
|
||||
overlay.addEventListener("click",e=>{if(e.target===overlay)overlay.style.display="none";});
|
||||
document.addEventListener("keydown",e=>{if(e.key==="Escape")overlay.style.display="none";});
|
||||
setMode("paste"); // 붙여넣기 탭 기본 활성화
|
||||
setMode("auto"); // 자동 > 구간 JSON 탭 기본 활성화
|
||||
|
||||
// 시간 입력 자동 변환(4314→43:14, 0302→03:02) + 시작 입력시 끝=시작+1:30
|
||||
function fmtTime(v){
|
||||
|
||||
Loading…
Reference in New Issue
Block a user