Compare commits

..

No commits in common. "34ea42a5e45828f12f7f8e5ad82359791b4641d3" and "f38776d71fc5baad879ce1c01325b7f074a23355" have entirely different histories.

8 changed files with 69 additions and 203 deletions

View File

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

View File

@ -26,14 +26,12 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고,
화면 자막은 그대로 JSON의 `bottom`을 쓸지, Whisper 자동 자막으로 바꿀지 따로 고를 수 있습니다.) 화면 자막은 그대로 JSON의 `bottom`을 쓸지, Whisper 자동 자막으로 바꿀지 따로 고를 수 있습니다.)
### ▶ 유튜브 구간 ### ▶ 유튜브 구간
한 URL + 여러 구간( 구간 추가) → 이어붙여 **무음컷(선택) + 자동 자막(Whisper)**. 한 URL + 여러 구간( 구간 추가) → 이어붙여 **무음컷 + 자동 자막(Whisper)**.
무음 제거는 아래 **영상 옵션**의 체크박스로 켜고 끕니다(기본 켬).
📋 붙여넣기 탭과 같은 흐름 — 분석(다운로드·무음·받아쓰기)이 끝나면 h-lab 댓글을 📋 붙여넣기 탭과 같은 흐름 — 분석(다운로드·무음·받아쓰기)이 끝나면 h-lab 댓글을
구간별로 자동 추천해 검토 화면을 보여주고, 카드를 고른 뒤 드래프트를 만듭니다. 구간별로 자동 추천해 검토 화면을 보여주고, 카드를 고른 뒤 드래프트를 만듭니다.
### 📁 파일 ### 📁 파일
로컬 영상 파일 → **무음컷(선택) + 자동 자막**. (댓글 카드는 폴더 지정 방식만 — 검토 화면 없음) 로컬 영상 파일 → **무음컷 + 자동 자막**. (댓글 카드는 폴더 지정 방식만 — 검토 화면 없음)
무음 제거는 아래 **영상 옵션**의 체크박스로 켜고 끕니다(기본 켬).
세 방법 모두 아래 **영상 옵션**을 함께 적용합니다. 세 방법 모두 아래 **영상 옵션**을 함께 적용합니다.
@ -102,7 +100,7 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고,
5. **CapCut** 설치 5. **CapCut** 설치
6. `캡컷_에이전트_구간합치기.bat` 실행 6. `캡컷_에이전트_구간합치기.bat` 실행
> **코트라 볼드체**(제목·출처) / **제주명조체**(하단 자막): 새 PC의 CapCut 에서 그 폰트를 한 번 사용하면 캐시가 생겨 자동 적용됩니다. 없으면 기본 폰트로 나옵니다(에러 아님). > **코트라 볼드체**: 새 PC의 CapCut 에서 그 폰트를 한 번 사용하면 캐시가 생겨 자동 적용됩니다. 없으면 기본 폰트로 나옵니다(에러 아님).
> **드래프트 저장 위치**: 그 PC의 CapCut 프로젝트 폴더를 자동 인식합니다. > **드래프트 저장 위치**: 그 PC의 CapCut 프로젝트 폴더를 자동 인식합니다.
> **.gemini_key**: '파일'·'유튜브 구간' 탭 자막 교정용. 붙여넣기 탭만 쓰면 없어도 됩니다. > **.gemini_key**: '파일'·'유튜브 구간' 탭 자막 교정용. 붙여넣기 탭만 쓰면 없어도 됩니다.
@ -118,7 +116,7 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고,
| 다운로드가 자꾸 깨짐 | `python -m pip install -U yt-dlp` (유튜브가 가끔 바뀜) | | 다운로드가 자꾸 깨짐 | `python -m pip install -U yt-dlp` (유튜브가 가끔 바뀜) |
| 자막이 밀림 | 붙여넣기 탭은 컷·자막을 그대로 쓰므로 안 밀림. 파일/유튜브 탭은 Whisper 타이밍 사용 | | 자막이 밀림 | 붙여넣기 탭은 컷·자막을 그대로 쓰므로 안 밀림. 파일/유튜브 탭은 Whisper 타이밍 사용 |
| 자막 `\n` 이 글자로 박힘 | .bat 재시작하면 해결(앱이 `\n`·`\\n` 자동 분할) | | 자막 `\n` 이 글자로 박힘 | .bat 재시작하면 해결(앱이 `\n`·`\\n` 자동 분할) |
| 폰트가 다르게 나옴 | CapCut 에서 코트라 볼드체·제주명조체를 한 번 사용해 캐시 생성 | | 폰트가 다르게 나옴 | CapCut 에서 코트라 볼드체 한 번 사용해 캐시 생성 |
--- ---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -123,9 +123,7 @@ COMMENT_MAX_BOTTOM = 1760
# 자동 맞춤(fit_caption_size)은 드래프트마다 크기가 달라져서 고정으로 바꿈. # 자동 맞춤(fit_caption_size)은 드래프트마다 크기가 달라져서 고정으로 바꿈.
# 청킹 하드캡이 14자라 크기 10 이면 한 줄 폭 ≈ 14×51.5 ≈ 721px < 1080 → 넘칠 일 없음. # 청킹 하드캡이 14자라 크기 10 이면 한 줄 폭 ≈ 14×51.5 ≈ 721px < 1080 → 넘칠 일 없음.
CAPTION_SIZE = 12.0 CAPTION_SIZE = 12.0
# 하단 자막 스타일(캡컷 인스펙터 실측과 1:1): 제주명조체 · 흰색 · 검은 획 40 · 배경/그림자 없음. CAPTION_COLOR = (1.0, 128 / 255, 0.0) # #ff8000 주황
CAPTION_COLOR = (1.0, 1.0, 1.0) # 흰색
CAPTION_BORDER_W = 40.0 # 획(외곽선) 두께 — 캡컷 UI 값과 동일 스케일
# 자막 한 줄 맞춤: 캡컷 글꼴크기 ↔ Malgun Bold 픽셀 보정(size13≈67px → 5.15px/unit) # 자막 한 줄 맞춤: 캡컷 글꼴크기 ↔ Malgun Bold 픽셀 보정(size13≈67px → 5.15px/unit)
CAPTION_PX_PER_UNIT = 5.15 CAPTION_PX_PER_UNIT = 5.15
@ -269,9 +267,8 @@ def build_bg_template_draft(
t = t.replace(a, b) t = t.replace(a, b)
return [ln.strip() for ln in t.split("\n") if ln.strip()] return [ln.strip() for ln in t.split("\n") if ln.strip()]
# 배경 박스 없음(background 인자 생략) + 흰색 + 검은 획 40. 폰트는 저장 후 제주명조체 주입. # 배경 박스 없음(background 인자 생략) + 주황 #ff8000 + 그림자(저장 후 주입).
cap_style = p.TextStyle(size=CAPTION_SIZE, bold=False, color=CAPTION_COLOR, align=1) cap_style = p.TextStyle(size=CAPTION_SIZE, bold=True, 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: for ts, te, text in captions:
if _us(te) - _us(ts) <= 0 or not text: if _us(te) - _us(ts) <= 0 or not text:
continue continue
@ -286,7 +283,7 @@ def build_bg_template_draft(
continue continue
script.add_segment(p.TextSegment( script.add_segment(p.TextSegment(
ln, p.Timerange(_us(a), seg), ln, p.Timerange(_us(a), seg),
style=cap_style, border=cap_border, style=cap_style,
clip_settings=p.ClipSettings(transform_y=caption_y), clip_settings=p.ClipSettings(transform_y=caption_y),
), "caption") ), "caption")
@ -367,8 +364,7 @@ def build_bg_template_draft(
script.save() script.save()
draft_dir = os.path.join(draft_root, draft_name) draft_dir = os.path.join(draft_root, draft_name)
_apply_font_to_texts(draft_dir, KOTRA_BOLD) # 모든 텍스트에 코트라 볼드체 주입 _apply_font_to_texts(draft_dir, KOTRA_BOLD) # 모든 텍스트에 코트라 볼드체 주입
_apply_font_to_track(draft_dir, "caption", JEJU_MYEONGJO) # 하단 자막만 제주명조체 _apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW) # 하단 자막 그림자
# 하단 자막은 그림자 없음(획 40 으로 대체) — _apply_shadow_to_track 호출하지 않는다.
_lock_tracks(draft_dir, LOCK_TRACKS) # 오버레이·제목 트랙 잠금(편집 중 레이어 꼬임 방지) _lock_tracks(draft_dir, LOCK_TRACKS) # 오버레이·제목 트랙 잠금(편집 중 레이어 꼬임 방지)
if bg_white and title_top: # 서브제목에 그림자 주입(캡컷 실측 형식) if bg_white and title_top: # 서브제목에 그림자 주입(캡컷 실측 형식)
_apply_shadow_to_text(draft_dir, title_top, _TEXT_SHADOW) _apply_shadow_to_text(draft_dir, title_top, _TEXT_SHADOW)
@ -628,55 +624,6 @@ 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: def _apply_font_to_texts(draft_dir: str, font: dict) -> None:
"""저장된 draft_content.json 의 모든 텍스트 재질 스타일에 폰트 주입. """저장된 draft_content.json 의 모든 텍스트 재질 스타일에 폰트 주입.
@ -741,7 +688,10 @@ def _apply_shadow_to_track(draft_dir: str, track_name: str, shadow: dict) -> Non
if not os.path.isfile(jf): if not os.path.isfile(jf):
return return
j = json.load(open(jf, encoding="utf-8")) j = json.load(open(jf, encoding="utf-8"))
ids = _text_material_ids(j, track_name) 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", []))
if not ids: if not ids:
return return
for m in j["materials"].get("texts", []): for m in j["materials"].get("texts", []):

View File

@ -202,16 +202,11 @@ BG_STEPS: List[Dict[str, str]] = [
] ]
def bg_steps(youtube: Optional[dict], remove_silence: bool = True) -> List[Dict[str, str]]: def bg_steps(youtube: Optional[dict]) -> List[Dict[str, str]]:
"""배경템플릿 파이프라인의 manifest 스텝. 쪼갠 두 조각(bg_analyze/bg_draft)이 함께 내는 전체 목록. """배경템플릿 파이프라인의 manifest 스텝. 쪼갠 두 조각(bg_analyze/bg_draft)이 함께 내는 전체 목록."""
remove_silence=False silence 스텝을 아예 낸다 bg_analyze 스텝을
내지 않으므로 manifest 남겨두면 화면에서 영원히 대기 상태로 보인다.
"""
steps = ([{"id": "download", "label": "유튜브 여러 구간 다운로드·병합"}] if youtube else []) steps = ([{"id": "download", "label": "유튜브 여러 구간 다운로드·병합"}] if youtube else [])
if remove_silence: return steps + [{"id": "silence", "label": "무음·발화 분석"},
steps.append({"id": "silence", "label": "무음·발화 분석"}) {"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
return steps + [{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
{"id": "draft", "label": "템플릿 드래프트 생성"}] {"id": "draft", "label": "템플릿 드래프트 생성"}]
@ -223,16 +218,11 @@ async def bg_analyze(
title_main: str = "", title_main: str = "",
channel: str = "", channel: str = "",
youtube: Optional[dict] = None, youtube: Optional[dict] = None,
remove_silence: bool = True,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""배경템플릿 파이프라인 앞부분: [유튜브 구간 다운로드] → probe → [무음컷] → 받아쓰기. """배경템플릿 파이프라인 앞부분: [유튜브 구간 다운로드] → probe → 무음컷 → 받아쓰기.
마지막에 `bg_draft` 이어줄 `{"type": "state", ...}` 낸다. 마지막에 `bg_draft` 이어줄 `{"type": "state", ...}` 낸다.
(오디오가 전부 무음이면 `error` 내고 조용히 끝난다 이때는 `state` 나온다.) (오디오가 전부 무음이면 `error` 내고 조용히 끝난다 이때는 `state` 나온다.)
remove_silence=False 무음 분석을 아예 돌리고 `keep = [(0, duration)]` 덩어리로
간다 `cut_plan`·`_remap_placements` 항등 매핑이 자막·카드 좌표가 그대로 남는다
(붙여넣기 파이프라인 `paste_analyze` 같은 패턴). 이때 `places == raw_places`.
""" """
t_all = time.perf_counter() t_all = time.perf_counter()
use_gemini = has_gemini_key() use_gemini = has_gemini_key()
@ -275,8 +265,7 @@ async def bg_analyze(
meta = await asyncio.to_thread(probe, video_path) meta = await asyncio.to_thread(probe, video_path)
yield {"type": "log", "msg": f"{meta.width}×{meta.height} · {meta.fps}fps · {meta.duration:.1f}s"} yield {"type": "log", "msg": f"{meta.width}×{meta.height} · {meta.fps}fps · {meta.duration:.1f}s"}
# ── silence (오디오 무음 컷) — 선택. 끄면 통짜 한 덩어리(항등 매핑) ── # ── silence (오디오 무음 컷) ──
if remove_silence:
yield {"type": "step", "id": "silence", "status": "start"} yield {"type": "step", "id": "silence", "status": "start"}
t = time.perf_counter() t = time.perf_counter()
keep = await asyncio.to_thread( keep = await asyncio.to_thread(
@ -293,10 +282,6 @@ async def bg_analyze(
yield {"type": "step", "id": "silence", "status": "done", yield {"type": "step", "id": "silence", "status": "done",
"elapsed": round(time.perf_counter() - t, 1), "elapsed": round(time.perf_counter() - t, 1),
"detail": f"보존 {len(keep)}구간 · {meta.duration - kept:.1f}s 무음 제거"} "detail": f"보존 {len(keep)}구간 · {meta.duration - kept:.1f}s 무음 제거"}
else:
keep = [(0.0, meta.duration)] # 통짜 → cut_plan/_remap 이 항등 매핑
places = list(raw_places)
yield {"type": "log", "msg": "무음 제거 꺼짐 — 구간을 그대로 이어붙입니다."}
# ── asr (받아쓰기): 타이밍=Whisper 단어 타임스탬프(정확, 드리프트 없음). # ── asr (받아쓰기): 타이밍=Whisper 단어 타임스탬프(정확, 드리프트 없음).
# 글자 품질만 Gemini로 제자리 교정(1:1, 시간은 절대 안 건드림) → 싱크 유지 ── # 글자 품질만 Gemini로 제자리 교정(1:1, 시간은 절대 안 건드림) → 싱크 유지 ──
@ -425,18 +410,16 @@ async def process_bg_template(
cards_fixed: bool = False, cards_fixed: bool = False,
bg_white: bool = False, bg_white: bool = False,
youtube: Optional[dict] = None, youtube: Optional[dict] = None,
remove_silence: bool = True,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""배경템플릿 파이프라인 — 📁 파일 탭 / ▶ 유튜브 구간 탭용 얇은 래퍼. """배경템플릿 파이프라인 — 📁 파일 탭 / ▶ 유튜브 구간 탭용 얇은 래퍼.
analyze/draft 조각을 연달아 부른다. `state` 이벤트는 밖으로 흘린다 analyze/draft 조각을 연달아 부른다. `state` 이벤트는 밖으로 흘린다
(기존 UI가 모르는 타입이라 흘리면 로그에 정체불명 이벤트가 찍힌다). (기존 UI가 모르는 타입이라 흘리면 로그에 정체불명 이벤트가 찍힌다).
""" """
yield {"type": "manifest", "steps": bg_steps(youtube, remove_silence)} yield {"type": "manifest", "steps": bg_steps(youtube)}
state = None state = None
async for ev in bg_analyze(video_path, draft_name, title_top=title_top, async for ev in bg_analyze(video_path, draft_name, title_top=title_top,
title_main=title_main, channel=channel, youtube=youtube, title_main=title_main, channel=channel, youtube=youtube):
remove_silence=remove_silence):
if ev.get("type") == "state": if ev.get("type") == "state":
state = ev["state"] state = ev["state"]
continue continue

View File

@ -148,7 +148,6 @@ async def upload(
scene: str = Form(""), scene: str = Form(""),
comments_dir: str = Form(""), comments_dir: str = Form(""),
bg_white: str = Form(""), bg_white: str = Form(""),
remove_silence: str = Form("1"),
) -> JSONResponse: ) -> JSONResponse:
data = await file.read() data = await file.read()
# content hash → 같은 영상 재업로드 시 캐시/멱등 (mtime 아님) # content hash → 같은 영상 재업로드 시 캐시/멱등 (mtime 아님)
@ -165,7 +164,6 @@ async def upload(
"title_top": title_top, "title_main": title_main, "channel": channel, "title_top": title_top, "title_main": title_main, "channel": channel,
"video_scale": _scale(video_scale), "flip": _truthy(flip), "video_scale": _scale(video_scale), "flip": _truthy(flip),
"scene": _truthy(scene), "comments_dir": comments_dir, "bg_white": _truthy(bg_white), "scene": _truthy(scene), "comments_dir": comments_dir, "bg_white": _truthy(bg_white),
"remove_silence": _truthy(remove_silence),
} }
return JSONResponse({"job_id": h, "draft_name": JOBS[h]["draft_name"]}) return JSONResponse({"job_id": h, "draft_name": JOBS[h]["draft_name"]})
@ -264,7 +262,6 @@ async def stream(job_id: str) -> StreamingResponse:
bg_white=job.get("bg_white", False), bg_white=job.get("bg_white", False),
youtube=job.get("youtube"), youtube=job.get("youtube"),
cards_fixed=job.get("cards_fixed", False), cards_fixed=job.get("cards_fixed", False),
remove_silence=job.get("remove_silence", True),
) )
async for ev in stream_iter: async for ev in stream_iter:
yield _sse(ev) yield _sse(ev)
@ -283,7 +280,6 @@ async def yt_analyze(
title_top: str = Form(""), title_top: str = Form(""),
title_main: str = Form(""), title_main: str = Form(""),
channel: str = Form(""), channel: str = Form(""),
remove_silence: str = Form("1"),
) -> JSONResponse: ) -> JSONResponse:
"""▶ 유튜브 구간 탭(새 흐름) 1단계 — URL·구간 검증 후 분석 예약. 실제 작업은 /yt/stream 에서. """▶ 유튜브 구간 탭(새 흐름) 1단계 — URL·구간 검증 후 분석 예약. 실제 작업은 /yt/stream 에서.
@ -297,11 +293,9 @@ async def yt_analyze(
rng = _parse_ranges(ranges) rng = _parse_ranges(ranges)
if not rng: if not rng:
return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400) return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400)
rs = _truthy(remove_silence) aid = hashlib.sha1(("yt|" + u + "|" + ranges).encode()).hexdigest()[:12]
aid = hashlib.sha1(("yt|" + u + "|" + ranges + "|rs" + ("1" if rs else "0")).encode()
).hexdigest()[:12]
ANALYSES[aid] = {"url": u, "ranges": rng, "title_top": title_top, ANALYSES[aid] = {"url": u, "ranges": rng, "title_top": title_top,
"title_main": title_main, "channel": channel, "remove_silence": rs} "title_main": title_main, "channel": channel}
return JSONResponse({"analysis_id": aid}) return JSONResponse({"analysis_id": aid})
@ -320,9 +314,8 @@ async def yt_stream(aid: str) -> StreamingResponse:
warnings: list[str] = [] warnings: list[str] = []
# draft 스텝은 이 단계(analyze)에서 안 돈다 — /yt/build 때 별도 스트림으로 실행되므로 # draft 스텝은 이 단계(analyze)에서 안 돈다 — /yt/build 때 별도 스트림으로 실행되므로
# 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다. # 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다.
rs = a.get("remove_silence", True)
yield _sse({"type": "manifest", "steps": yield _sse({"type": "manifest", "steps":
[s for s in bg_steps(youtube, rs) if s["id"] != "draft"] + [s for s in bg_steps(youtube) if s["id"] != "draft"] +
[{"id": "comments", "label": "댓글 수집 (h-lab)"}, [{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"}]}) {"id": "recommend", "label": "컷별 댓글 추천"}]})
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url)) com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
@ -333,7 +326,7 @@ async def yt_stream(aid: str) -> StreamingResponse:
title_top=a.get("title_top", ""), title_top=a.get("title_top", ""),
title_main=a.get("title_main", ""), title_main=a.get("title_main", ""),
channel=a.get("channel", ""), channel=a.get("channel", ""),
youtube=youtube, remove_silence=rs): youtube=youtube):
if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다 if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다
state = ev["state"] state = ev["state"]
continue continue
@ -364,9 +357,8 @@ async def yt_stream(aid: str) -> StreamingResponse:
yield _sse({"type": "step", "id": "recommend", "status": "start"}) yield _sse({"type": "step", "id": "recommend", "status": "start"})
# ⚠ 좌표계 둘: places/captions = 압축 타임라인(카드·자막 추출용), # ⚠ 좌표계 둘: places/captions = 압축 타임라인(카드·자막 추출용),
# ranges_sec = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다. # ranges_sec = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
# state["places"] 는 bg_analyze 가 이미 최종 타임라인 좌표로 만들어 둔 것이다 # bg_analyze 의 무음 제거는 항상 켜져 있어 state["places"] 는 이미 압축 좌표다
# (무음 제거 켬 = 압축 좌표 / 끔 = raw_places 그대로). 여기서 _remap_placements 를 # (_remap_placements 를 여기서 또 부르면 두 번 압축된다 — 부르지 않는다).
# 또 부르면 두 번 압축된다 — 부르지 않는다.
places = state["places"] places = state["places"]
orig = state["ranges_sec"] orig = state["ranges_sec"]
try: try:
@ -457,8 +449,7 @@ async def yt_build(
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out: with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body) out.write(body)
# ⚠ state["places"] 는 bg_analyze 가 이미 최종 타임라인 좌표로 만든 것이다 # ⚠ state["places"] 는 bg_analyze 가 이미 무음 제거를 반영해 압축한 좌표다.
# (무음 제거 켬 = 압축 좌표 / 끔 = 원본 그대로).
# 여기서 또 재매핑하지 않는다 — 카드 시간은 이 좌표를 그대로 컷 구간으로 쓴다. # 여기서 또 재매핑하지 않는다 — 카드 시간은 이 좌표를 그대로 컷 구간으로 쓴다.
cut_cards = _cards_by_cut(_card_paths(cdir), cut_map, state["places"], state["total"], cut_cards = _cards_by_cut(_card_paths(cdir), cut_map, state["places"], state["total"],
fixed=_truthy(cards_fixed)) fixed=_truthy(cards_fixed))
@ -732,13 +723,10 @@ def _hl_paste_payload(paste: dict) -> dict:
@app.post("/auto/prepare") @app.post("/auto/prepare")
async def auto_prepare(aid: str = Form(...), ids: str = Form(...), async def auto_prepare(aid: str = Form(...), ids: str = Form(...)) -> JSONResponse:
remove_silence: str = Form("1")) -> JSONResponse:
"""자동 탭 2단계 — 검토 화면에서 제외하지 않은 ID만 준비 예약. 실제 작업은 /auto/prepare/{pid} 에서. """자동 탭 2단계 — 검토 화면에서 제외하지 않은 ID만 준비 예약. 실제 작업은 /auto/prepare/{pid} 에서.
ids: 남길 하이라이트 id의 JSON 배열(: [1,2,4]) 제외된 ID는 여기 들어온다. ids: 남길 하이라이트 id의 JSON 배열(: [1,2,4]) 제외된 ID는 여기 들어온다.
remove_silence: 무음 제거 여부(기본 ). 받아쓰기(asr) 달리 댓글 매칭에 필수가 아니다
매칭은 원본 시각(orig) 기준이고 paste_analyze False 항등 매핑으로 돈다.
""" """
a = ANALYSES.get(aid) a = ANALYSES.get(aid)
if not a or not a.get("highlights"): if not a or not a.get("highlights"):
@ -749,9 +737,8 @@ async def auto_prepare(aid: str = Form(...), ids: str = Form(...),
raise ValueError raise ValueError
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError):
return JSONResponse({"error": "준비할 ID 목록이 올바르지 않습니다."}, 400) return JSONResponse({"error": "준비할 ID 목록이 올바르지 않습니다."}, 400)
rs = _truthy(remove_silence) pid = hashlib.sha1((aid + "|" + ids).encode()).hexdigest()[:12]
pid = hashlib.sha1((aid + "|" + ids + "|rs" + ("1" if rs else "0")).encode()).hexdigest()[:12] PREPARES[pid] = {"aid": aid, "ids": id_list}
PREPARES[pid] = {"aid": aid, "ids": id_list, "remove_silence": rs}
return JSONResponse({"prepare_id": pid}) return JSONResponse({"prepare_id": pid})
@ -816,8 +803,7 @@ async def auto_prepare_stream(pid: str) -> StreamingResponse:
payload = _hl_paste_payload(h["paste"]) # dict 컷 → 튜플 컷 (필수 — docstring 참고) payload = _hl_paste_payload(h["paste"]) # dict 컷 → 튜플 컷 (필수 — docstring 참고)
try: try:
async for ev in paste_analyze(payload, f"auto_{aid}_{hid}", async for ev in paste_analyze(payload, f"auto_{aid}_{hid}",
remove_silence=p.get("remove_silence", True), remove_silence=True, asr_bottom=True,
asr_bottom=True,
name_suffix=safe_tag): name_suffix=safe_tag):
if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다 if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다
state = ev["state"] state = ev["state"]

View File

@ -526,15 +526,14 @@ function curMode(){
const r=document.querySelector('input[name="amode"]:checked'); const r=document.querySelector('input[name="amode"]:checked');
return r?r.value:"full"; return r?r.value:"full";
} }
// ⚠ 네 모드 전부 /auto/prepare 가 댓글 매칭을 위해 asr_bottom=True 로 고정해서 돈다. // ⚠ 네 모드 전부 /auto/prepare 가 댓글 매칭을 위해 remove_silence=True·asr_bottom=True 로
// 무음 제거는 패널의 #autoRmsilence 체크박스(기본 켬)를 폼으로 넘겨 선택 가능 — // 고정해서 돈다(공통 옵션의 "무음 제거" 체크박스는 자동 탭에서 숨겨져 있다 — setMode() 참조).
// 댓글 매칭은 원본 시각(orig) 기준이라 무음 제거를 꺼도 어긋나지 않는다. // "무음 제거는 아래 공통 옵션을 따른다"는 예전 문구가 이 사실과 어긋나 오해를 낳았었다.
// (공통 옵션의 "무음 제거" 체크박스는 자동 탭에서 여전히 숨김 — setMode() 참조.)
const MODE_HELP={ const MODE_HELP={
full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다. 받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만들고, 제목은 JSON의 title_top·title_main을 그대로 씁니다. 1차 검토 없이 바로 다운로드·받아쓰기·댓글 매칭까지 진행되고 댓글 선택 화면으로 갑니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. JSON에 title_top·title_main이 있으면 제목(윗줄·아랫줄)이 자동으로 채워지고, 검토 화면에서 ID별로 고칠 수 있습니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
}; };
const PASTE_UI={ const PASTE_UI={
paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)", paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)",
@ -554,12 +553,8 @@ function applyMode(){
} }
$("#autoUrlNote").textContent=p?p.note:""; $("#autoUrlNote").textContent=p?p.note:"";
$("#autoModeHelp").textContent=MODE_HELP[m]; $("#autoModeHelp").textContent=MODE_HELP[m];
$("#autoGo").textContent=(p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)")+ $("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)";
(autoRunOn()?" → 영상까지 자동":"");
} }
/* " " (1 · 2 )
기본값·추천 그대로 다음 단계로 넘어간다. 화면 편의 기능이라 서버는 상태를 모른다. */
function autoRunOn(){const el=$("#autoAutoRun");return !!(el&&el.checked);}
/* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */ /* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */
async function analyze(){ async function analyze(){
@ -596,15 +591,7 @@ async function analyze(){
if(ev.type==="manifest") renderASteps(ev.steps); if(ev.type==="manifest") renderASteps(ev.steps);
else if(ev.type==="step") updateAStep(ev); else if(ev.type==="step") updateAStep(ev);
else if(ev.type==="log") alog(ev.msg); else if(ev.type==="log") alog(ev.msg);
else if(ev.type==="result"){es.close();onResult(ev);doneA(); else if(ev.type==="result"){es.close();onResult(ev);doneA();}
// 구간 JSON 모드: 제목이 JSON에 이미 있어 1차 검토(제목 선택)가 무의미 →
// 유효 구간이 있으면(autoPrepGo 표시 = ok>0) 바로 준비(다운로드·받아쓰기·댓글 매칭) 시작.
// "끝까지 진행"이 켜져 있으면 나머지 모드도 제목 기본값(첫 후보)으로 그냥 넘어간다.
if((m==="wpaste"||autoRunOn())&&$("#autoPrepGo").style.display==="block"){
alog(m==="wpaste"?"구간 JSON 모드 — 제목이 JSON에 있으므로 바로 준비를 시작합니다."
:"끝까지 진행 — 제목은 기본 후보로 두고 바로 준비를 시작합니다.");
prepareAll();
}}
else if(ev.type==="error"){es.close();failA(ev.message);} else if(ev.type==="error"){es.close();failA(ev.message);}
}; };
es.onerror=()=>{es.close();failA("연결이 끊겼습니다.");}; es.onerror=()=>{es.close();failA("연결이 끊겼습니다.");};
@ -720,8 +707,6 @@ async function prepareAll(){
const fd=new FormData(); const fd=new FormData();
fd.append("aid",AUTO_AID); fd.append("aid",AUTO_AID);
fd.append("ids",JSON.stringify(ids)); fd.append("ids",JSON.stringify(ids));
const rs=$("#autoRmsilence");
fd.append("remove_silence",(!rs||rs.checked)?"1":""); // 체크박스 없으면 기본 켬
res=await(await fetch("/auto/prepare",{method:"POST",body:fd})).json(); res=await(await fetch("/auto/prepare",{method:"POST",body:fd})).json();
}catch(e){return prepFail("요청 실패: "+e);} }catch(e){return prepFail("요청 실패: "+e);}
if(res.error) return prepFail(res.error); if(res.error) return prepFail(res.error);
@ -835,13 +820,6 @@ function onPrepareResult(ev){
$("#autoBuild").style.display="block"; $("#autoBuild").style.display="block";
updateBuildBtn(); updateBuildBtn();
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex"; if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex";
// "끝까지 진행" — 댓글 선택 화면에서 기다리지 않고, 위에서 자동 선택해 둔 컷별 추천을
// 그대로 확정해 바로 빌드. setTimeout(0)으로 이 SSE 핸들러를 끝낸 뒤 시작해야
// buildAll 의 캡처가 방금 그린 DOM의 레이아웃 확정 후에 돈다.
if(autoRunOn()&&!$("#autoBuild").disabled){
prepLog("끝까지 진행 — 추천 댓글을 그대로 쓰고 바로 영상 생성으로 넘어갑니다.");
setTimeout(buildAll,0);
}
} }
/* ── 캡처 + 순차 빌드 ── */ /* ── 캡처 + 순차 빌드 ── */
@ -1092,9 +1070,6 @@ function ytAnalyze(){
fd.append("title_top",$("#yttop").value); fd.append("title_top",$("#yttop").value);
fd.append("title_main",$("#ytmain").value); fd.append("title_main",$("#ytmain").value);
fd.append("channel",$("#ytchan").value); fd.append("channel",$("#ytchan").value);
// 무음 제거는 공통 옵션의 #rmsilence 체크박스(구간 탭에서 보임)를 그대로 넘긴다.
// 예전엔 이 값을 아무도 안 읽어서 체크를 꺼도 bg_analyze 가 무조건 무음을 잘랐다.
fd.append("remove_silence",$("#rmsilence")&&!$("#rmsilence").checked?"0":"1");
fetch("/yt/analyze",{method:"POST",body:fd}).then(r=>r.json()).then(res=>{ fetch("/yt/analyze",{method:"POST",body:fd}).then(r=>r.json()).then(res=>{
if(res.error){ytFail(res.error);return;} if(res.error){ytFail(res.error);return;}
YT_AID=res.analysis_id; YT_AID=res.analysis_id;
@ -1346,7 +1321,6 @@ document.addEventListener("DOMContentLoaded",()=>{
$("#autoUrl").addEventListener("keydown",(e)=>{if(e.key==="Enter")analyze();}); $("#autoUrl").addEventListener("keydown",(e)=>{if(e.key==="Enter")analyze();});
document.querySelectorAll('input[name="amode"]').forEach(r=> document.querySelectorAll('input[name="amode"]').forEach(r=>
r.addEventListener("change",applyMode)); r.addEventListener("change",applyMode));
if($("#autoAutoRun")) $("#autoAutoRun").addEventListener("change",applyMode);
applyMode(); applyMode();
$("#autoPrepGo").addEventListener("click",prepareAll); $("#autoPrepGo").addEventListener("click",prepareAll);
$("#autoBuild").addEventListener("click",buildAll); $("#autoBuild").addEventListener("click",buildAll);
@ -1358,15 +1332,12 @@ document.addEventListener("DOMContentLoaded",()=>{
if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll); if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll);
}); });
/* localStorage , . /* localStorage , .
서버는 상태를 모른다( 전송 값만 ) 순수 화면 편의 기능. */ 서버는 상태를 모른다( 전송 값만 ) 순수 화면 편의 기능. */
(function(){ (function(){
for(const [id,key] of [["#bgwhite","opt_bgwhite"],["#autoRmsilence","opt_auto_rmsilence"], const bw=$("#bgwhite"); if(!bw) return;
["#autoAutoRun","opt_auto_autorun"]]){ const saved=localStorage.getItem("opt_bgwhite");
const el=$(id); if(!el) continue; if(saved!==null) bw.checked=saved==="1";
const saved=localStorage.getItem(key); bw.addEventListener("change",()=>localStorage.setItem("opt_bgwhite",bw.checked?"1":"0"));
if(saved!==null) el.checked=saved==="1";
el.addEventListener("change",()=>localStorage.setItem(key,el.checked?"1":"0"));
}
})(); })();
})(); })();

View File

@ -464,18 +464,6 @@
style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;" style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12px;line-height:1.5;resize:vertical;"
placeholder='오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요.&#10;블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.'></textarea> placeholder='오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요.&#10;블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.'></textarea>
</div> </div>
<div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="autoRmsilence" checked style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (컷 안의 무음까지 잘라냄)
</label>
<div class="note" style="margin-top:2px;">받아쓰기(Whisper)·댓글 매칭은 항상 돌지만, 무음 제거는 꺼도 매칭이 어긋나지 않습니다.</div>
</div>
<div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="autoAutoRun" checked style="accent-color:var(--accent);width:15px;height:15px;"> 끝까지 진행 (검토 없이 영상 생성까지)
</label>
<div class="note" style="margin-top:2px;">켜면 중간에 멈추지 않습니다 — 제목은 기본값, 댓글은 컷별 <b>추천 그대로</b> 쓰고 바로 드래프트를 만듭니다. 끄면 지금처럼 댓글 선택 화면에서 기다립니다.</div>
</div>
<button class="run" id="autoGo" style="margin-top:8px;">분석 시작 (하이라이트 5개)</button> <button class="run" id="autoGo" style="margin-top:8px;">분석 시작 (하이라이트 5개)</button>
<div class="note" id="autoModeHelp" style="margin-top:8px;"> <div class="note" id="autoModeHelp" style="margin-top:8px;">
Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서
@ -561,9 +549,8 @@
</div> </div>
<div class="field" id="rmsilenceField"> <div class="field" id="rmsilenceField">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="rmsilence" checked style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (컷 안의 무음까지 잘라냄) <input type="checkbox" id="rmsilence" style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (붙여넣기: 컷 안의 무음까지 잘라냄)
</label> </label>
<div class="note" style="margin-top:2px;">끄면 구간을 자른 그대로 이어붙입니다(받아쓰기·자막은 그대로 돕니다).</div>
</div> </div>
<div class="field"> <div class="field">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
@ -637,9 +624,8 @@ function setMode(m){
$("#titleGroup").style.display=(m==="paste"||m==="auto"||m==="yt")?"none":"block"; $("#titleGroup").style.display=(m==="paste"||m==="auto"||m==="yt")?"none":"block";
$("#cdirField").style.display=m==="auto"?"none":"block"; $("#cdirField").style.display=m==="auto"?"none":"block";
$("#run").style.display=(m==="auto"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용 $("#run").style.display=(m==="auto"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용
// 붙여넣기 탭은 댓글 매칭을 위해 무음 제거·받아쓰기가 항상 켜진다(/paste stream 이 // 붙여넣기·자동 탭은 댓글 매칭을 위해 무음 제거·받아쓰기가 항상 켜진다(/paste·/auto prepare
// remove_silence=True 로 고정) — 끌 수 있는 것처럼 보이면 안 되므로 체크박스 자체를 숨긴다. // 가 remove_silence=True 로 고정) — 끌 수 있는 것처럼 보이면 안 되므로 체크박스 자체를 숨긴다.
// 자동 탭은 패널 안 전용 체크박스(#autoRmsilence)를 쓰므로 공통 체크박스는 계속 숨긴다.
$("#rmsilenceField").style.display=(m==="paste"||m==="auto")?"none":"block"; $("#rmsilenceField").style.display=(m==="paste"||m==="auto")?"none":"block";
} }
$("#tab-file").addEventListener("click",()=>setMode("file")); $("#tab-file").addEventListener("click",()=>setMode("file"));
@ -781,7 +767,7 @@ drop.addEventListener("drop",ev=>{const f=ev.dataTransfer.files[0];if(f)setFile(
function fmtSize(b){if(b>1e9)return(b/1e9).toFixed(1)+" GB";if(b>1e6)return(b/1e6).toFixed(1)+" MB";return(b/1e3).toFixed(0)+" KB";} function fmtSize(b){if(b>1e9)return(b/1e9).toFixed(1)+" GB";if(b>1e6)return(b/1e6).toFixed(1)+" MB";return(b/1e3).toFixed(0)+" KB";}
function setFile(f){picked=f;filechip.innerHTML=`<span class="k"></span> ${f.name} <span class="k tnum">· ${fmtSize(f.size)}</span>`;filerow.style.display="block";} function setFile(f){picked=f;filechip.innerHTML=`<span class="k"></span> ${f.name} <span class="k tnum">· ${fmtSize(f.size)}</span>`;filerow.style.display="block";}
function addLog(m){logEl.style.display="block";const d=document.createElement("div");d.className="line";d.textContent=m;logEl.appendChild(d);} function addLog(m){logEl.style.display="block";const d=document.createElement("div");d.className="line";d.textContent=m;logEl.appendChild(d);}
function titleFields(fd){fd.append("title_top",$("#ttop").value);fd.append("title_main",$("#tmain").value);fd.append("channel",$("#chan").value);fd.append("video_scale",$("#vscale").value||"144");fd.append("flip",$("#flip").checked?"1":"0");fd.append("scene",$("#scene").checked?"1":"0");fd.append("comments_dir",$("#cdir").value.trim());fd.append("bg_white",$("#bgwhite").checked?"1":"0");fd.append("remove_silence",$("#rmsilence").checked?"1":"0");} function titleFields(fd){fd.append("title_top",$("#ttop").value);fd.append("title_main",$("#tmain").value);fd.append("channel",$("#chan").value);fd.append("video_scale",$("#vscale").value||"144");fd.append("flip",$("#flip").checked?"1":"0");fd.append("scene",$("#scene").checked?"1":"0");fd.append("comments_dir",$("#cdir").value.trim());fd.append("bg_white",$("#bgwhite").checked?"1":"0");}
runBtn.addEventListener("click",async()=>{ runBtn.addEventListener("click",async()=>{
window._runStart=Date.now(); // 시작~끝 총 시간 측정 window._runStart=Date.now(); // 시작~끝 총 시간 측정