fix: stabilize YouTube cuts and draft generation

This commit is contained in:
hehihoho3@gmail.com 2026-08-18 17:01:46 +09:00
parent 76714e4bde
commit 4927c61ac5
7 changed files with 68 additions and 23 deletions

View File

@ -114,8 +114,8 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고,
|------|------| |------|------|
| 수정한 게 반영 안 됨 | 검은 창 닫고 **.bat 재시작** | | 수정한 게 반영 안 됨 | 검은 창 닫고 **.bat 재시작** |
| `python not found` | Python 재설치 시 PATH 체크 후 재부팅 | | `python not found` | Python 재설치 시 PATH 체크 후 재부팅 |
| 유튜브 `Video unavailable` | 영상이 실제 공개인지 확인 → `python -m pip install -U yt-dlp` | | 유튜브 `Video unavailable` | 영상이 실제 공개인지 확인 → `python -m pip install -U --pre "yt-dlp[default]"` |
| 다운로드가 자꾸 깨짐 | `python -m pip install -U yt-dlp` (유튜브가 가끔 바뀜) | | 다운로드가 자꾸 깨짐 / `HTTP 403` | `python -m pip install -U --pre "yt-dlp[default]"` (nightly + EJS 구성요소 업데이트) |
| 자막이 밀림 | 붙여넣기 탭은 컷·자막을 그대로 쓰므로 안 밀림. 파일/유튜브 탭은 Whisper 타이밍 사용 | | 자막이 밀림 | 붙여넣기 탭은 컷·자막을 그대로 쓰므로 안 밀림. 파일/유튜브 탭은 Whisper 타이밍 사용 |
| 자막 `\n` 이 글자로 박힘 | .bat 재시작하면 해결(앱이 `\n`·`\\n` 자동 분할) | | 자막 `\n` 이 글자로 박힘 | .bat 재시작하면 해결(앱이 `\n`·`\\n` 자동 분할) |
| 폰트가 다르게 나옴 | CapCut 에서 코트라 볼드체·제주명조체를 한 번씩 사용해 캐시 생성 | | 폰트가 다르게 나옴 | CapCut 에서 코트라 볼드체·제주명조체를 한 번씩 사용해 캐시 생성 |

View File

@ -135,7 +135,7 @@ python-multipart==0.0.20
pyCapCut==0.0.3 # import 이름은 pycapcut pyCapCut==0.0.3 # import 이름은 pycapcut
Pillow==10.4.0 Pillow==10.4.0
pymediainfo==7.0.1 pymediainfo==7.0.1
yt-dlp==2026.7.4 yt-dlp[default]>=2026.8.17.73947.dev0
faster-whisper==1.2.1 faster-whisper==1.2.1
# ── faster-whisper / fastapi 전이 의존성(자동 설치되지만 버전 고정용) ── # ── 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` **불필요**. > - SSE(진행상황 스트림)는 FastAPI 내장 `StreamingResponse` 사용 → `sse-starlette` **불필요**.
> - Gemini 교정은 표준 라이브러리 `urllib`로 REST 직접 호출 → `google-generativeai` **패키지 불필요**. > - Gemini 교정은 표준 라이브러리 `urllib`로 REST 직접 호출 → `google-generativeai` **패키지 불필요**.
> - `pymediainfo`는 내부적으로 **MediaInfo DLL**을 씀. 대개 wheel에 포함되나, 안 되면 https://mediaarea.net/en/MediaInfo 의 DLL을 PATH에 두면 됨. > - `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`/`ffprobe` not found | PATH 미등록 | ffmpeg `bin`을 시스템 PATH에 추가, 창 새로 열기 |
| 유튜브 다운로드가 ffmpeg 크래시(exit 3436169992) | JS 런타임 없음 | Node.js 또는 deno 설치 | | 유튜브 다운로드가 ffmpeg 크래시(exit 3436169992) | JS 런타임 없음 | Node.js 또는 deno 설치 |
| "Video unavailable" | 영상 자체 없음/지역제한 | `curl "https://www.youtube.com/oembed?url=<URL>&format=json"` 404면 영상 문제 | | "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 복사 (없어도 동작은 함) | | 자막이 기본 폰트로 나옴 | 코트라 볼드체 캐시 없음 | §6 — CapCut에서 1회 사용 or font.ttf 복사 (없어도 동작은 함) |
| ASR 첫 실행이 매우 느림/멈춘 듯 | Whisper medium(1.5GB) 다운로드 중 | 최초 1회. 인터넷 확인, 기다리기 | | ASR 첫 실행이 매우 느림/멈춘 듯 | Whisper medium(1.5GB) 다운로드 중 | 최초 1회. 인터넷 확인, 기다리기 |
| 자막 교정이 안 됨 | Gemini 키 없음/429 | 선택 기능. 없으면 Whisper 원문 사용(정상) | | 자막 교정이 안 됨 | Gemini 키 없음/429 | 선택 기능. 없으면 Whisper 원문 사용(정상) |
@ -306,7 +306,7 @@ python-multipart 0.0.20
pyCapCut 0.0.3 (import pycapcut) pyCapCut 0.0.3 (import pycapcut)
Pillow 10.4.0 Pillow 10.4.0
pymediainfo 7.0.1 pymediainfo 7.0.1
yt-dlp 2026.7.4 (최신 유지 권장) yt-dlp 2026.8.17 nightly 이상 (`yt-dlp[default]`, 최신 유지 권장)
faster-whisper 1.2.1 faster-whisper 1.2.1
├ av 16.0.1 ├ av 16.0.1
├ ctranslate2 4.6.2 ├ ctranslate2 4.6.2

BIN
assets/intro_dingdong.mp3 Normal file

Binary file not shown.

View File

@ -22,6 +22,9 @@ Clip = Tuple[float, float, Optional[str]]
DEFAULT_DRAFT_ROOT = os.path.join( DEFAULT_DRAFT_ROOT = os.path.join(
os.environ["LOCALAPPDATA"], "CapCut", "User Data", "Projects", "com.lveditor.draft" 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: def _us(seconds: float) -> int:
@ -230,6 +233,8 @@ def build_bg_template_draft(
script.add_track(p.TrackType.text, "channel", relative_index=4) script.add_track(p.TrackType.text, "channel", relative_index=4)
if effect_captions: if effect_captions:
script.add_track(p.TrackType.text, "effect", relative_index=5) 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)로 컷 끝 클램프 # 영상 소재 로드 + 실제 소재 길이(µs)로 컷 끝 클램프
# ffprobe 가 보고한 길이가 CapCut 이 보는 소재 길이보다 살짝 길 때(마지막 프레임) # ffprobe 가 보고한 길이가 CapCut 이 보는 소재 길이보다 살짝 길 때(마지막 프레임)
@ -247,6 +252,17 @@ def build_bg_template_draft(
# 타임라인 길이 = 보존 비디오 구간 합(클램프 반영) # 타임라인 길이 = 보존 비디오 구간 합(클램프 반영)
total_us = sum(e - s for s, e in clips_us) 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: if has_bg:
bg_mat = p.VideoMaterial(bg_image_path) 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, cap_style = p.TextStyle(size=18.0 if politics_style else CAPTION_SIZE,
bold=False, color=CAPTION_COLOR, align=1) bold=False, color=CAPTION_COLOR, align=1)
cap_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=CAPTION_BORDER_W) 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: if _us(te) - _us(ts) <= 0 or not text:
continue continue
lines = _cap_lines(text) lines = _cap_lines(text)
@ -287,14 +304,19 @@ def build_bg_template_draft(
# 구간을 줄 수만큼 균등 분할 → i번째 줄이 i번째 시간대에 표시(같은 위치) # 구간을 줄 수만큼 균등 분할 → i번째 줄이 i번째 시간대에 표시(같은 위치)
a = ts + (te - ts) * i / n a = ts + (te - ts) * i / n
b = ts + (te - ts) * (i + 1) / 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: if seg <= 0:
continue continue
script.add_segment(p.TextSegment( script.add_segment(p.TextSegment(
ln, p.Timerange(_us(a), seg), ln, p.Timerange(a_us, seg),
style=cap_style, border=cap_border, style=cap_style, border=cap_border,
clip_settings=p.ClipSettings(transform_y=caption_y), clip_settings=p.ClipSettings(transform_y=caption_y),
), "caption") ), "caption")
caption_end_us = b_us
# 효과 자막(중앙): 하단 자막 '바로 위'. 주황 볼드 + 검은 외곽선(배경박스 없음). # 효과 자막(중앙): 하단 자막 '바로 위'. 주황 볼드 + 검은 외곽선(배경박스 없음).
if effect_captions: if effect_captions:
@ -302,15 +324,19 @@ def build_bg_template_draft(
eff_style = p.TextStyle(size=13.0, bold=False, eff_style = p.TextStyle(size=13.0, bold=False,
color=(13/255, 255/255, 99/255), align=1) # #0dff63 녹색 color=(13/255, 255/255, 99/255), align=1) # #0dff63 녹색
eff_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0) eff_border = p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0)
for ts, te, text in effect_captions: effect_end_us = 0
dur = _us(te) - _us(ts) 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: if dur <= 0 or not text:
continue continue
script.add_segment(p.TextSegment( script.add_segment(p.TextSegment(
text, p.Timerange(_us(ts), dur), text, p.Timerange(start_us, dur),
style=eff_style, border=eff_border, style=eff_style, border=eff_border,
clip_settings=p.ClipSettings(transform_y=eff_y), clip_settings=p.ClipSettings(transform_y=eff_y),
), "effect") ), "effect")
effect_end_us = end_us
# 프레임(상하 검은 띠 + 투명 가운데) — 영상 위, 전체 길이 # 프레임(상하 검은 띠 + 투명 가운데) — 영상 위, 전체 길이
frame_mat = p.VideoMaterial(frame_image_path) frame_mat = p.VideoMaterial(frame_image_path)
@ -330,8 +356,11 @@ def build_bg_template_draft(
# CapCut scale 1.0 = contain. 댓글 카드는 캔버스(9:16)보다 가로로 넓으므로 # CapCut scale 1.0 = contain. 댓글 카드는 캔버스(9:16)보다 가로로 넓으므로
# 가로가 먼저 맞아 표시 높이 = 1080 × (h/w) × scale. # 가로가 먼저 맞아 표시 높이 = 1080 × (h/w) × scale.
if comment_cards: if comment_cards:
for ts, te, img in comment_cards: comment_end_us = 0
dur = _us(te) - _us(ts) 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): if dur <= 0 or not img or not os.path.isfile(img):
continue continue
eff = COMMENT_SCALE eff = COMMENT_SCALE
@ -346,10 +375,11 @@ def build_bg_template_draft(
else: else:
cy = comment_y if comment_y is not None else round(-1162/1920, 4) cy = comment_y if comment_y is not None else round(-1162/1920, 4)
script.add_segment(p.VideoSegment( 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, clip_settings=p.ClipSettings(scale_x=eff, scale_y=eff,
transform_x=0.0, transform_y=cy), transform_x=0.0, transform_y=cy),
), "comment") ), "comment")
comment_end_us = end_us
full = p.Timerange(0, total_us) full = p.Timerange(0, total_us)
# 제목 두 줄: 윗줄 = 주황 포인트색, 아랫줄 = 흰색. 둘 다 크게+볼드+검은 외곽선. # 제목 두 줄: 윗줄 = 주황 포인트색, 아랫줄 = 흰색. 둘 다 크게+볼드+검은 외곽선.

View File

@ -30,6 +30,21 @@ TIME_RE = re.compile(r"^(?:\d{1,2}:)?\d{1,2}:[0-5]\d$")
TIMEOUT_SEC = 1800 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: def valid_time(t: str) -> bool:
return bool(TIME_RE.match(t.strip())) 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, proc = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=TIMEOUT_SEC, env=env) encoding="utf-8", errors="replace", timeout=TIMEOUT_SEC, env=env)
if proc.returncode != 0: 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}") raise RuntimeError(f"yt-dlp 실패:\n{tail}")
# stdout 에서 채널명 파싱 (경로는 폴더 스캔으로 확정 — 인코딩 의존 제거) # stdout 에서 채널명 파싱 (경로는 폴더 스캔으로 확정 — 인코딩 의존 제거)
@ -349,7 +364,7 @@ def _dl_section(url: str, start_sec: float, end_sec: float, out_dir: str,
if proc.returncode != 0: if proc.returncode != 0:
proc = _run([]) proc = _run([])
if proc.returncode != 0: 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}") raise RuntimeError(f"yt-dlp 실패:\n{tail}")
channel = "" channel = ""

View File

@ -12,5 +12,5 @@ python-multipart==0.0.20
pyCapCut==0.0.3 pyCapCut==0.0.3
Pillow==10.4.0 Pillow==10.4.0
pymediainfo==7.0.1 pymediainfo==7.0.1
yt-dlp==2026.7.4 yt-dlp[default]>=2026.8.17.73947.dev0
faster-whisper==1.2.1 # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요) faster-whisper==1.2.1 # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요)

View File

@ -362,14 +362,14 @@
</header> </header>
<div class="tabs"> <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-yt">▶ 유튜브 구간</button>
<button class="tab" id="tab-paste">📋 붙여넣기</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>
<!-- 파일 모드 --> <!-- 파일 모드 -->
<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 class="helprow"><button type="button" class="helpbtn" data-help="file">? 사용법</button></div>
<div id="drop"> <div id="drop">
<div class="big">영상을 여기에 드롭하거나 클릭</div> <div class="big">영상을 여기에 드롭하거나 클릭</div>
@ -439,7 +439,7 @@
</div> </div>
<!-- 자동(Gemini) 모드 --> <!-- 자동(Gemini) 모드 -->
<div id="panel-auto" style="display:none;"> <div id="panel-auto">
<div class="card" style="padding:18px;"> <div class="card" style="padding:18px;">
<div class="field"> <div class="field">
<label>방식</label> <label>방식</label>
@ -709,7 +709,7 @@ document.querySelectorAll(".helpbtn").forEach(b=>b.addEventListener("click",()=>
$("#helpClose").addEventListener("click",()=>overlay.style.display="none"); $("#helpClose").addEventListener("click",()=>overlay.style.display="none");
overlay.addEventListener("click",e=>{if(e.target===overlay)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";}); 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 // 시간 입력 자동 변환(4314→43:14, 0302→03:02) + 시작 입력시 끝=시작+1:30
function fmtTime(v){ function fmtTime(v){