feat: 하단 자막을 제주명조체·흰색·검은 획 40 으로 (배경·그림자 없음)

캡컷 인스펙터 실측 스크린샷과 1:1 로 맞춤:
- 글꼴 제주명조체(JEJU_MYEONGJO, 캐시 id 7480851064179133702) — 자막 트랙에만 주입
- 색상 주황 #ff8000 → 흰색, 볼드 해제
- 획(외곽선) 검정 두께 40 추가 (CAPTION_BORDER_W)
- 그림자 제거 (_apply_shadow_to_track 을 caption 에 안 부름)
- 크기 12·배경 없음은 기존 그대로

폰트는 전체 주입(_apply_font_to_texts, 코트라 볼드체) 뒤에 새 헬퍼
_apply_font_to_track 으로 caption 트랙만 덮어쓴다 → 제목·출처는 코트라 볼드체 유지.
캐시 없는 PC 에서는 주입이 생략돼 안전하게 코트라 볼드체로 남는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-10 22:10:17 +09:00
parent f38776d71f
commit f66b396d71
3 changed files with 72 additions and 14 deletions

View File

@ -240,11 +240,17 @@ title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효
핵심 구현 포인트:
- **소재 길이 클램프**: ffprobe duration이 CapCut 소재 길이보다 수십 ms 길 수 있음
→ 컷 끝을 `material.duration`으로 클램프(SegmentOverlap/초과 오류 방어).
- **자막 스타일**: **주황 `#ff8000`**(`CAPTION_COLOR`) + 볼드 + **그림자**, **배경박스 없음**,
- **자막 스타일**: **제주명조체** + **흰색**(`CAPTION_COLOR`) + 볼드 아님 +
**검은 획(외곽선) 두께 40**(`CAPTION_BORDER_W`), **배경박스 없음**, **그림자 없음**,
크기 **`CAPTION_SIZE = 12.0` 고정**(캡컷 폰트 크기 1:1).
배경은 `TextSegment(background=...)` 인자를 **생략**해서 끈다 → `background_style` 키 자체가
안 나가고 CapCut 이 읽으면서 0(없음)으로 채운다(제목 텍스트가 원래 이 방식).
그림자는 저장 후 `_apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW)` 로 주입.
폰트는 저장 후 `_apply_font_to_track(draft_dir, "caption", JEJU_MYEONGJO)` 로 **자막 트랙에만**
주입한다(전체 주입 `_apply_font_to_texts(…, KOTRA_BOLD)` 뒤에 덮어씀 → 제목·출처는 코트라 볼드체).
획 두께는 pycapcut `TextBorder(width=…)` 가 캡컷 UI 값과 같은 0~100 스케일
(JSON 엔 `width/100*0.2` = 0.08 로 나감).
⚠ 하단 자막 그림자는 **끔**`_apply_shadow_to_track` 은 남아 있지만 자막엔 호출하지 않는다
(되돌리려면 `build_bg_template_draft` 끝의 주석 줄 참고).
예전엔 최장 줄 기준 `fit_caption_size()`로 7~13 자동이었으나 드래프트마다 크기가
달라져 고정으로 바꿈. 청킹 하드캡 14자 × 크기10(≈51.5px) ≈ 721px < 1080 넘침.
`fit_caption_size()`는 남겨둠(미사용, 자동 맞춤으로 되돌릴 때 사용).
@ -265,6 +271,8 @@ title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효
모든 텍스트 styles[].font에 직접 주입**(`_apply_font_to_texts`). 경로는
`%LOCALAPPDATA%/CapCut/User Data/Cache/effect/7480846567709265157/...`(PC 무관).
캐시 없으면(그 PC CapCut에서 폰트 미사용) 주입 생략 → 기본 폰트로 안전 동작.
**하단 자막만 제주명조체**(`JEJU_MYEONGJO`, id `7480851064179133702`) —
전체 주입 뒤 `_apply_font_to_track(…, "caption", …)` 으로 덮어쓴다.
- **트랙 자동 잠금**(`_lock_tracks`, 저장 직후): `frame`·`comment`·`title_top`·
`title_main`·`channel` 트랙을 잠근다(`attribute |= 4`; mute 비트1은 OR로 보존).
`main`(영상)·`caption`(자막)·`effect`는 편집용이라 **안 잠금**. `bg`(흰 배경)도
@ -407,7 +415,7 @@ CHANNEL_RATIO = 0.85 # 아래 띠에서 85% 지점
| title_top (주황 size14) | 109 | 0.8865 | Y 1702 |
| title_main (흰색 size18) | 252 | 0.7375 | Y 1416 |
| 효과자막 (#0dff63 size13, bold X) | 348 | 0.6375 | Y 1224 |
| 하단 자막 (size **12**, **#ff8000**, 그림자, 배경 없음) | 1050 | 0.0938 | Y 180 |
| 하단 자막 (제주명조체 size **12**, **흰색**, 검은 획 40, 배경·그림자 없음) | 1050 | 0.0938 | Y 180 |
| 댓글 카드 (scale 0.89, X 0, 3초/장) | 윗변 1122 | 카드마다 계산 | — |
| channel (size 10) | 1800 | 0.8753 | Y 1681 |
| 영상 확대 기본 | — | — | 144% |

View File

@ -100,7 +100,7 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고,
5. **CapCut** 설치
6. `캡컷_에이전트_구간합치기.bat` 실행
> **코트라 볼드체**: 새 PC의 CapCut 에서 그 폰트를 한 번 사용하면 캐시가 생겨 자동 적용됩니다. 없으면 기본 폰트로 나옵니다(에러 아님).
> **코트라 볼드체**(제목·출처) / **제주명조체**(하단 자막): 새 PC의 CapCut 에서 그 폰트를 한 번 사용하면 캐시가 생겨 자동 적용됩니다. 없으면 기본 폰트로 나옵니다(에러 아님).
> **드래프트 저장 위치**: 그 PC의 CapCut 프로젝트 폴더를 자동 인식합니다.
> **.gemini_key**: '파일'·'유튜브 구간' 탭 자막 교정용. 붙여넣기 탭만 쓰면 없어도 됩니다.
@ -116,7 +116,7 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고,
| 다운로드가 자꾸 깨짐 | `python -m pip install -U yt-dlp` (유튜브가 가끔 바뀜) |
| 자막이 밀림 | 붙여넣기 탭은 컷·자막을 그대로 쓰므로 안 밀림. 파일/유튜브 탭은 Whisper 타이밍 사용 |
| 자막 `\n` 이 글자로 박힘 | .bat 재시작하면 해결(앱이 `\n`·`\\n` 자동 분할) |
| 폰트가 다르게 나옴 | CapCut 에서 코트라 볼드체 한 번 사용해 캐시 생성 |
| 폰트가 다르게 나옴 | CapCut 에서 코트라 볼드체·제주명조체를 한 번 사용해 캐시 생성 |
---

View File

@ -123,7 +123,9 @@ COMMENT_MAX_BOTTOM = 1760
# 자동 맞춤(fit_caption_size)은 드래프트마다 크기가 달라져서 고정으로 바꿈.
# 청킹 하드캡이 14자라 크기 10 이면 한 줄 폭 ≈ 14×51.5 ≈ 721px < 1080 → 넘칠 일 없음.
CAPTION_SIZE = 12.0
CAPTION_COLOR = (1.0, 128 / 255, 0.0) # #ff8000 주황
# 하단 자막 스타일(캡컷 인스펙터 실측과 1:1): 제주명조체 · 흰색 · 검은 획 40 · 배경/그림자 없음.
CAPTION_COLOR = (1.0, 1.0, 1.0) # 흰색
CAPTION_BORDER_W = 40.0 # 획(외곽선) 두께 — 캡컷 UI 값과 동일 스케일
# 자막 한 줄 맞춤: 캡컷 글꼴크기 ↔ Malgun Bold 픽셀 보정(size13≈67px → 5.15px/unit)
CAPTION_PX_PER_UNIT = 5.15
@ -267,8 +269,9 @@ def build_bg_template_draft(
t = t.replace(a, b)
return [ln.strip() for ln in t.split("\n") if ln.strip()]
# 배경 박스 없음(background 인자 생략) + 주황 #ff8000 + 그림자(저장 후 주입).
cap_style = p.TextStyle(size=CAPTION_SIZE, bold=True, color=CAPTION_COLOR, align=1)
# 배경 박스 없음(background 인자 생략) + 흰색 + 검은 획 40. 폰트는 저장 후 제주명조체 주입.
cap_style = p.TextStyle(size=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:
continue
@ -283,7 +286,7 @@ def build_bg_template_draft(
continue
script.add_segment(p.TextSegment(
ln, p.Timerange(_us(a), seg),
style=cap_style,
style=cap_style, border=cap_border,
clip_settings=p.ClipSettings(transform_y=caption_y),
), "caption")
@ -364,7 +367,8 @@ def build_bg_template_draft(
script.save()
draft_dir = os.path.join(draft_root, draft_name)
_apply_font_to_texts(draft_dir, KOTRA_BOLD) # 모든 텍스트에 코트라 볼드체 주입
_apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW) # 하단 자막 그림자
_apply_font_to_track(draft_dir, "caption", JEJU_MYEONGJO) # 하단 자막만 제주명조체
# 하단 자막은 그림자 없음(획 40 으로 대체) — _apply_shadow_to_track 호출하지 않는다.
_lock_tracks(draft_dir, LOCK_TRACKS) # 오버레이·제목 트랙 잠금(편집 중 레이어 꼬임 방지)
if bg_white and title_top: # 서브제목에 그림자 주입(캡컷 실측 형식)
_apply_shadow_to_text(draft_dir, title_top, _TEXT_SHADOW)
@ -624,6 +628,55 @@ KOTRA_BOLD = {
}
# 제주명조체(JejuMyeongjo) — 하단 자막 전용. 코트라 볼드체와 같은 CapCut 폰트 캐시 방식.
# 캐시가 없는 PC 에서는 주입이 생략돼 코트라 볼드체(전체 주입분)가 그대로 남는다.
JEJU_MYEONGJO = {
"path": os.path.join(
os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect",
"7480851064179133702", "6ab2b6b0bd71a41109ad5506d600f257", "font.ttf",
).replace("\\", "/"),
"id": "7480851064179133702",
}
def _text_material_ids(j: dict, track_name: str) -> set:
"""지정 텍스트 트랙 세그먼트들의 material_id 집합."""
ids = set()
for tr in j.get("tracks", []):
if tr.get("type") == "text" and tr.get("name") == track_name:
ids.update(s.get("material_id") for s in tr.get("segments", []))
return ids
def _apply_font_to_track(draft_dir: str, track_name: str, font: dict) -> None:
"""지정 텍스트 트랙의 소재에만 폰트 주입(_apply_font_to_texts 의 트랙 한정판).
전체 주입(코트라 볼드체) 뒤에 호출해 하단 자막만 다른 폰트로 덮어쓴다.
"""
import json
if not font.get("path") or not os.path.isfile(font["path"]):
return
jf = os.path.join(draft_dir, "draft_content.json")
if not os.path.isfile(jf):
return
j = json.load(open(jf, encoding="utf-8"))
ids = _text_material_ids(j, track_name)
if not ids:
return
for m in j["materials"].get("texts", []):
if m.get("id") not in ids:
continue
try:
c = json.loads(m["content"])
except Exception: # noqa: BLE001 — 파싱 안 되는 소재는 건너뜀
continue
for st in c.get("styles", []):
st["font"] = dict(font)
m["content"] = json.dumps(c, ensure_ascii=False)
with open(jf, "w", encoding="utf-8") as f:
json.dump(j, f, ensure_ascii=False)
def _apply_font_to_texts(draft_dir: str, font: dict) -> None:
"""저장된 draft_content.json 의 모든 텍스트 재질 스타일에 폰트 주입.
@ -688,10 +741,7 @@ def _apply_shadow_to_track(draft_dir: str, track_name: str, shadow: dict) -> Non
if not os.path.isfile(jf):
return
j = json.load(open(jf, encoding="utf-8"))
ids = set()
for tr in j.get("tracks", []):
if tr.get("type") == "text" and tr.get("name") == track_name:
ids.update(s.get("material_id") for s in tr.get("segments", []))
ids = _text_material_ids(j, track_name)
if not ids:
return
for m in j["materials"].get("texts", []):