Compare commits
4 Commits
feat/yt-ra
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 76714e4bde | |||
| 34ea42a5e4 | |||
| f66b396d71 | |||
| f38776d71f |
@ -240,11 +240,17 @@ title_top 서브제목 / title_main 메인제목 / channel 출처 / effect 효
|
|||||||
핵심 구현 포인트:
|
핵심 구현 포인트:
|
||||||
- **소재 길이 클램프**: ffprobe duration이 CapCut 소재 길이보다 수십 ms 길 수 있음
|
- **소재 길이 클램프**: ffprobe duration이 CapCut 소재 길이보다 수십 ms 길 수 있음
|
||||||
→ 컷 끝을 `material.duration`으로 클램프(SegmentOverlap/초과 오류 방어).
|
→ 컷 끝을 `material.duration`으로 클램프(SegmentOverlap/초과 오류 방어).
|
||||||
- **자막 스타일**: **주황 `#ff8000`**(`CAPTION_COLOR`) + 볼드 + **그림자**, **배경박스 없음**,
|
- **자막 스타일**: **제주명조체** + **흰색**(`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_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 자동이었으나 드래프트마다 크기가
|
예전엔 최장 줄 기준 `fit_caption_size()`로 7~13 자동이었으나 드래프트마다 크기가
|
||||||
달라져 고정으로 바꿈. 청킹 하드캡 14자 × 크기10(≈51.5px) ≈ 721px < 1080 → 안 넘침.
|
달라져 고정으로 바꿈. 청킹 하드캡 14자 × 크기10(≈51.5px) ≈ 721px < 1080 → 안 넘침.
|
||||||
`fit_caption_size()`는 남겨둠(미사용, 자동 맞춤으로 되돌릴 때 사용).
|
`fit_caption_size()`는 남겨둠(미사용, 자동 맞춤으로 되돌릴 때 사용).
|
||||||
@ -265,6 +271,8 @@ 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`(흰 배경)도
|
||||||
@ -407,7 +415,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**, **#ff8000**, 그림자, 배경 없음) | 1050 | −0.0938 | Y −180 |
|
| 하단 자막 (제주명조체 size **12**, **흰색**, 검은 획 40, 배경·그림자 없음) | 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% |
|
||||||
|
|||||||
10
README.md
10
README.md
@ -26,12 +26,14 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고,
|
|||||||
화면 자막은 그대로 JSON의 `bottom`을 쓸지, Whisper 자동 자막으로 바꿀지 따로 고를 수 있습니다.)
|
화면 자막은 그대로 JSON의 `bottom`을 쓸지, Whisper 자동 자막으로 바꿀지 따로 고를 수 있습니다.)
|
||||||
|
|
||||||
### ▶ 유튜브 구간
|
### ▶ 유튜브 구간
|
||||||
한 URL + 여러 구간(+ 구간 추가) → 이어붙여 **무음컷 + 자동 자막(Whisper)**.
|
한 URL + 여러 구간(+ 구간 추가) → 이어붙여 **무음컷(선택) + 자동 자막(Whisper)**.
|
||||||
|
무음 제거는 아래 **영상 옵션**의 체크박스로 켜고 끕니다(기본 켬).
|
||||||
📋 붙여넣기 탭과 같은 흐름 — 분석(다운로드·무음·받아쓰기)이 끝나면 h-lab 댓글을
|
📋 붙여넣기 탭과 같은 흐름 — 분석(다운로드·무음·받아쓰기)이 끝나면 h-lab 댓글을
|
||||||
구간별로 자동 추천해 검토 화면을 보여주고, 카드를 고른 뒤 드래프트를 만듭니다.
|
구간별로 자동 추천해 검토 화면을 보여주고, 카드를 고른 뒤 드래프트를 만듭니다.
|
||||||
|
|
||||||
### 📁 파일
|
### 📁 파일
|
||||||
로컬 영상 파일 → **무음컷 + 자동 자막**. (댓글 카드는 폴더 지정 방식만 — 검토 화면 없음)
|
로컬 영상 파일 → **무음컷(선택) + 자동 자막**. (댓글 카드는 폴더 지정 방식만 — 검토 화면 없음)
|
||||||
|
무음 제거는 아래 **영상 옵션**의 체크박스로 켜고 끕니다(기본 켬).
|
||||||
|
|
||||||
세 방법 모두 아래 **영상 옵션**을 함께 적용합니다.
|
세 방법 모두 아래 **영상 옵션**을 함께 적용합니다.
|
||||||
|
|
||||||
@ -100,7 +102,7 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고,
|
|||||||
5. **CapCut** 설치
|
5. **CapCut** 설치
|
||||||
6. `캡컷_에이전트_구간합치기.bat` 실행
|
6. `캡컷_에이전트_구간합치기.bat` 실행
|
||||||
|
|
||||||
> **코트라 볼드체**: 새 PC의 CapCut 에서 그 폰트를 한 번 사용하면 캐시가 생겨 자동 적용됩니다. 없으면 기본 폰트로 나옵니다(에러 아님).
|
> **코트라 볼드체**(제목·출처) / **제주명조체**(하단 자막): 새 PC의 CapCut 에서 그 폰트를 한 번 사용하면 캐시가 생겨 자동 적용됩니다. 없으면 기본 폰트로 나옵니다(에러 아님).
|
||||||
> **드래프트 저장 위치**: 그 PC의 CapCut 프로젝트 폴더를 자동 인식합니다.
|
> **드래프트 저장 위치**: 그 PC의 CapCut 프로젝트 폴더를 자동 인식합니다.
|
||||||
> **.gemini_key**: '파일'·'유튜브 구간' 탭 자막 교정용. 붙여넣기 탭만 쓰면 없어도 됩니다.
|
> **.gemini_key**: '파일'·'유튜브 구간' 탭 자막 교정용. 붙여넣기 탭만 쓰면 없어도 됩니다.
|
||||||
|
|
||||||
@ -116,7 +118,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 |
BIN
assets/politics_factlab_logo.png
Normal file
BIN
assets/politics_factlab_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 943 KiB |
@ -123,7 +123,9 @@ 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
|
||||||
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)
|
# 자막 한 줄 맞춤: 캡컷 글꼴크기 ↔ Malgun Bold 픽셀 보정(size13≈67px → 5.15px/unit)
|
||||||
CAPTION_PX_PER_UNIT = 5.15
|
CAPTION_PX_PER_UNIT = 5.15
|
||||||
@ -184,6 +186,7 @@ def build_bg_template_draft(
|
|||||||
comment_y: Optional[float] = None,
|
comment_y: Optional[float] = None,
|
||||||
comment_top: Optional[float] = None,
|
comment_top: Optional[float] = None,
|
||||||
bg_white: bool = False,
|
bg_white: bool = False,
|
||||||
|
logo_image: Optional[str] = None,
|
||||||
canvas: Tuple[int, int] = (SHORT_W, SHORT_H),
|
canvas: Tuple[int, int] = (SHORT_W, SHORT_H),
|
||||||
draft_root: str = DEFAULT_DRAFT_ROOT,
|
draft_root: str = DEFAULT_DRAFT_ROOT,
|
||||||
) -> str:
|
) -> str:
|
||||||
@ -198,6 +201,7 @@ def build_bg_template_draft(
|
|||||||
raise ValueError("video_clips 가 비어 있습니다.")
|
raise ValueError("video_clips 가 비어 있습니다.")
|
||||||
cw, ch = canvas
|
cw, ch = canvas
|
||||||
has_bg = bool(bg_image_path)
|
has_bg = bool(bg_image_path)
|
||||||
|
politics_style = bool(logo_image)
|
||||||
# 제목 두 줄(윗줄 포인트색 + 아랫줄 흰색)은 비어도 자리표시로 항상 표시 → 캡컷에서 편집
|
# 제목 두 줄(윗줄 포인트색 + 아랫줄 흰색)은 비어도 자리표시로 항상 표시 → 캡컷에서 편집
|
||||||
if not title_main:
|
if not title_main:
|
||||||
title_main = "메인제목"
|
title_main = "메인제목"
|
||||||
@ -206,11 +210,14 @@ def build_bg_template_draft(
|
|||||||
|
|
||||||
folder = p.DraftFolder(draft_root)
|
folder = p.DraftFolder(draft_root)
|
||||||
script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True)
|
script = folder.create_draft(draft_name, cw, ch, fps=meta.fps, allow_replace=True)
|
||||||
# 영상 타입 트랙: [bg] → main → frame(위). 텍스트는 자동으로 더 위.
|
# 영상 타입 트랙: [bg] → main → frame(위) → [logo].
|
||||||
if has_bg:
|
if has_bg:
|
||||||
script.add_track(p.TrackType.video, "bg", relative_index=0)
|
script.add_track(p.TrackType.video, "bg", relative_index=0)
|
||||||
script.add_track(p.TrackType.video, "main", relative_index=1 if has_bg else 0)
|
base_ri = 1 if has_bg else 0
|
||||||
script.add_track(p.TrackType.video, "frame", relative_index=2 if has_bg else 1)
|
script.add_track(p.TrackType.video, "main", relative_index=base_ri)
|
||||||
|
script.add_track(p.TrackType.video, "frame", relative_index=base_ri + 1)
|
||||||
|
if logo_image and os.path.isfile(logo_image):
|
||||||
|
script.add_track(p.TrackType.video, "logo", relative_index=base_ri + 2)
|
||||||
if comment_cards: # 댓글 카드(검은 띠 위) — 프레임보다 위 레이어
|
if comment_cards: # 댓글 카드(검은 띠 위) — 프레임보다 위 레이어
|
||||||
script.add_track(p.TrackType.video, "comment", relative_index=3 if has_bg else 2)
|
script.add_track(p.TrackType.video, "comment", relative_index=3 if has_bg else 2)
|
||||||
# 텍스트 트랙은 각각 다른 층(render_index)으로 → 겹침/누락 방지
|
# 텍스트 트랙은 각각 다른 층(render_index)으로 → 겹침/누락 방지
|
||||||
@ -267,8 +274,10 @@ 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 인자 생략) + 주황 #ff8000 + 그림자(저장 후 주입).
|
# 배경 박스 없음(background 인자 생략) + 흰색 + 검은 획 40. 폰트는 저장 후 제주명조체 주입.
|
||||||
cap_style = p.TextStyle(size=CAPTION_SIZE, bold=True, color=CAPTION_COLOR, align=1)
|
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:
|
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
|
||||||
@ -283,7 +292,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,
|
style=cap_style, border=cap_border,
|
||||||
clip_settings=p.ClipSettings(transform_y=caption_y),
|
clip_settings=p.ClipSettings(transform_y=caption_y),
|
||||||
), "caption")
|
), "caption")
|
||||||
|
|
||||||
@ -307,6 +316,14 @@ def build_bg_template_draft(
|
|||||||
frame_mat = p.VideoMaterial(frame_image_path)
|
frame_mat = p.VideoMaterial(frame_image_path)
|
||||||
script.add_segment(p.VideoSegment(frame_mat, p.Timerange(0, total_us)), "frame")
|
script.add_segment(p.VideoSegment(frame_mat, p.Timerange(0, total_us)), "frame")
|
||||||
|
|
||||||
|
# 정치팩트랩 로고: 기준 영상처럼 하단 검정 영역 중앙에 고정한다.
|
||||||
|
if logo_image and os.path.isfile(logo_image):
|
||||||
|
script.add_segment(p.VideoSegment(
|
||||||
|
p.VideoMaterial(logo_image), p.Timerange(0, total_us),
|
||||||
|
clip_settings=p.ClipSettings(scale_x=0.27, scale_y=0.27,
|
||||||
|
transform_x=0.0, transform_y=_ty(1640, ch)),
|
||||||
|
), "logo")
|
||||||
|
|
||||||
# 댓글 카드: 하단 띠에 순서대로. 확대 89% 고정, X 0.
|
# 댓글 카드: 하단 띠에 순서대로. 확대 89% 고정, X 0.
|
||||||
# 세로 위치는 comment_top(윗변 픽셀)이 주어지면 **카드마다** 계산 —
|
# 세로 위치는 comment_top(윗변 픽셀)이 주어지면 **카드마다** 계산 —
|
||||||
# 카드 이미지 높이가 제각각이라 중앙값 하나로는 "영상 바로 아래"에 못 붙인다.
|
# 카드 이미지 높이가 제각각이라 중앙값 하나로는 "영상 바로 아래"에 못 붙인다.
|
||||||
@ -339,17 +356,20 @@ def build_bg_template_draft(
|
|||||||
# 흰 배경일 땐: 메인제목 외곽선 두께 50, 채널 글씨 검정(안 보임 방지), 서브제목 그림자.
|
# 흰 배경일 땐: 메인제목 외곽선 두께 50, 채널 글씨 검정(안 보임 방지), 서브제목 그림자.
|
||||||
main_border_w = 50.0 if bg_white else 18.0
|
main_border_w = 50.0 if bg_white else 18.0
|
||||||
channel_color = (0.0, 0.0, 0.0) if bg_white else (1.0, 1.0, 1.0)
|
channel_color = (0.0, 0.0, 0.0) if bg_white else (1.0, 1.0, 1.0)
|
||||||
if title_top: # 서브제목: 주황, 크기 14
|
if title_top: # 정치: 흰색 / 예능: 주황
|
||||||
script.add_segment(p.TextSegment(
|
script.add_segment(p.TextSegment(
|
||||||
title_top, full,
|
title_top, full,
|
||||||
style=p.TextStyle(size=14.0, bold=True, color=TITLE_ACCENT, align=1),
|
style=p.TextStyle(size=17.0 if politics_style else 14.0, bold=True,
|
||||||
|
color=((1.0, 1.0, 1.0) if politics_style else TITLE_ACCENT), align=1),
|
||||||
border=p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0),
|
border=p.TextBorder(color=(0.0, 0.0, 0.0), width=18.0),
|
||||||
clip_settings=p.ClipSettings(transform_y=title_top_y),
|
clip_settings=p.ClipSettings(transform_y=title_top_y),
|
||||||
), "title_top")
|
), "title_top")
|
||||||
if title_main: # 메인제목: 흰색, 크기 18
|
if title_main: # 정치: 빨강 / 예능: 흰색
|
||||||
script.add_segment(p.TextSegment(
|
script.add_segment(p.TextSegment(
|
||||||
title_main, full,
|
title_main, full,
|
||||||
style=p.TextStyle(size=18.0, bold=True, color=(1.0, 1.0, 1.0), align=1),
|
style=p.TextStyle(size=20.0 if politics_style else 18.0, bold=True,
|
||||||
|
color=((1.0, 0.18, 0.18) if politics_style else (1.0, 1.0, 1.0)),
|
||||||
|
align=1),
|
||||||
border=p.TextBorder(color=(0.0, 0.0, 0.0), width=main_border_w),
|
border=p.TextBorder(color=(0.0, 0.0, 0.0), width=main_border_w),
|
||||||
clip_settings=p.ClipSettings(transform_y=title_main_y),
|
clip_settings=p.ClipSettings(transform_y=title_main_y),
|
||||||
), "title_main")
|
), "title_main")
|
||||||
@ -357,14 +377,19 @@ def build_bg_template_draft(
|
|||||||
# 이미지 설정: 글꼴 크기 10, 가운데 (흰 배경이면 검정)
|
# 이미지 설정: 글꼴 크기 10, 가운데 (흰 배경이면 검정)
|
||||||
script.add_segment(p.TextSegment(
|
script.add_segment(p.TextSegment(
|
||||||
channel, full,
|
channel, full,
|
||||||
style=p.TextStyle(size=10.0, bold=False, color=channel_color, align=1),
|
style=p.TextStyle(size=12.0 if politics_style else 10.0, bold=False,
|
||||||
|
color=channel_color, align=1),
|
||||||
clip_settings=p.ClipSettings(transform_y=channel_y),
|
clip_settings=p.ClipSettings(transform_y=channel_y),
|
||||||
), "channel")
|
), "channel")
|
||||||
|
|
||||||
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_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW) # 하단 자막 그림자
|
_apply_font_to_track(draft_dir, "caption",
|
||||||
|
CAFE24_DANJUNGHAE if politics_style else JEJU_MYEONGJO)
|
||||||
|
if politics_style:
|
||||||
|
_apply_shadow_to_track(draft_dir, "caption", _TEXT_SHADOW)
|
||||||
|
# 예능 하단 자막은 그림자 없음(획 40 으로 대체).
|
||||||
_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)
|
||||||
@ -377,7 +402,7 @@ def build_bg_template_draft(
|
|||||||
# main(영상 편집)·caption(자막 편집)·effect 는 편집해야 하므로 잠그지 않는다.
|
# main(영상 편집)·caption(자막 편집)·effect 는 편집해야 하므로 잠그지 않는다.
|
||||||
# bg(흰 배경)도 잠그지 않는다 — 맨 아래 레이어라 꼬여도 화면에 영향이 없고,
|
# bg(흰 배경)도 잠그지 않는다 — 맨 아래 레이어라 꼬여도 화면에 영향이 없고,
|
||||||
# 영상 길이를 늘릴 때 같이 늘려야 해서 잠겨 있으면 불편하다(사용자 요청).
|
# 영상 길이를 늘릴 때 같이 늘려야 해서 잠겨 있으면 불편하다(사용자 요청).
|
||||||
LOCK_TRACKS = ("frame", "comment", "title_top", "title_main", "channel")
|
LOCK_TRACKS = ("frame", "comment", "logo", "title_top", "title_main", "channel")
|
||||||
|
|
||||||
|
|
||||||
# 비디오 트랙 이름 → 정상 render_index(아래→위). 생성 시 pycapcut 이 매기는 값과 동일.
|
# 비디오 트랙 이름 → 정상 render_index(아래→위). 생성 시 pycapcut 이 매기는 값과 동일.
|
||||||
@ -624,6 +649,65 @@ 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 카페24 단정해 — 정치 숏폼 자막. CapCut에서 사용자가 맞춘 기준 드래프트의
|
||||||
|
# 실제 폰트 소재 ID/캐시 경로를 그대로 사용한다.
|
||||||
|
CAFE24_DANJUNGHAE = {
|
||||||
|
"path": os.path.join(
|
||||||
|
os.environ.get("LOCALAPPDATA", ""), "CapCut", "User Data", "Cache", "effect",
|
||||||
|
"7528305055972199681", "0e4893968fe2d82714917f69c69826aa", "font.ttf",
|
||||||
|
).replace("\\", "/"),
|
||||||
|
"id": "7528305055972199681",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 의 모든 텍스트 재질 스타일에 폰트 주입.
|
||||||
|
|
||||||
@ -688,10 +772,7 @@ 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 = set()
|
ids = _text_material_ids(j, track_name)
|
||||||
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", []):
|
||||||
|
|||||||
@ -202,11 +202,16 @@ BG_STEPS: List[Dict[str, str]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def bg_steps(youtube: Optional[dict]) -> List[Dict[str, str]]:
|
def bg_steps(youtube: Optional[dict], remove_silence: bool = True) -> 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 [])
|
||||||
return steps + [{"id": "silence", "label": "무음·발화 분석"},
|
if remove_silence:
|
||||||
{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
|
steps.append({"id": "silence", "label": "무음·발화 분석"})
|
||||||
|
return steps + [{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"},
|
||||||
{"id": "draft", "label": "템플릿 드래프트 생성"}]
|
{"id": "draft", "label": "템플릿 드래프트 생성"}]
|
||||||
|
|
||||||
|
|
||||||
@ -218,11 +223,16 @@ 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()
|
||||||
@ -265,7 +275,8 @@ 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(
|
||||||
@ -282,6 +293,10 @@ 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, 시간은 절대 안 건드림) → 싱크 유지 ──
|
||||||
@ -410,16 +425,18 @@ 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)}
|
yield {"type": "manifest", "steps": bg_steps(youtube, remove_silence)}
|
||||||
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
|
||||||
@ -439,6 +456,8 @@ async def process_bg_template(
|
|||||||
CANVAS_H = 1920
|
CANVAS_H = 1920
|
||||||
VIDEO_TOP = 323 # 영상 창 시작 = 위 흰 띠가 끝나는 지점
|
VIDEO_TOP = 323 # 영상 창 시작 = 위 흰 띠가 끝나는 지점
|
||||||
VIDEO_BOTTOM = 1122 # 영상 창 끝 = 아래 흰 띠가 시작하는 지점
|
VIDEO_BOTTOM = 1122 # 영상 창 끝 = 아래 흰 띠가 시작하는 지점
|
||||||
|
POLITICS_VIDEO_TOP = 470
|
||||||
|
POLITICS_VIDEO_BOTTOM = 1450
|
||||||
TITLE_TOP_Y = 109 # 서브제목(주황) 중앙
|
TITLE_TOP_Y = 109 # 서브제목(주황) 중앙
|
||||||
TITLE_MAIN_Y = 252 # 메인제목(흰색) 중앙
|
TITLE_MAIN_Y = 252 # 메인제목(흰색) 중앙
|
||||||
CAPTION_GAP = 72 # 하단 자막 중앙 = VIDEO_BOTTOM − 이 값 (영상 창 안쪽 아래)
|
CAPTION_GAP = 72 # 하단 자막 중앙 = VIDEO_BOTTOM − 이 값 (영상 창 안쪽 아래)
|
||||||
@ -447,7 +466,7 @@ COMMENT_TOP = VIDEO_BOTTOM # 댓글 카드 윗변 = 영상 바로 아래(딱
|
|||||||
CHANNEL_RATIO = 0.85 # 출처: 아래 띠에서 85% 내려간 지점
|
CHANNEL_RATIO = 0.85 # 출처: 아래 띠에서 85% 내려간 지점
|
||||||
|
|
||||||
|
|
||||||
def _template_pos(white: bool = False):
|
def _template_pos(white: bool = False, profile: str = "entertainment"):
|
||||||
"""배경템플릿 프레임/배경 생성 + 위치 dict 계산. (frame_path, bg_path, pos) 반환.
|
"""배경템플릿 프레임/배경 생성 + 위치 dict 계산. (frame_path, bg_path, pos) 반환.
|
||||||
|
|
||||||
white=True 면 상하 띠·빈 곳을 흰색으로(흰 띠 프레임 + 흰 배경 레이어),
|
white=True 면 상하 띠·빈 곳을 흰색으로(흰 띠 프레임 + 흰 배경 레이어),
|
||||||
@ -455,22 +474,26 @@ def _template_pos(white: bool = False):
|
|||||||
좌표는 전부 위 레이아웃 상수에서 파생 — 한 곳만 고치면 된다.
|
좌표는 전부 위 레이아웃 상수에서 파생 — 한 곳만 고치면 된다.
|
||||||
"""
|
"""
|
||||||
band = (255, 255, 255) if white else (0, 0, 0)
|
band = (255, 255, 255) if white else (0, 0, 0)
|
||||||
fname = "frame_template_white.png" if white else "frame_template.png"
|
suffix = "_politics" if profile == "politics" else ""
|
||||||
frame = os.path.join(_ROOT, "assets", fname)
|
fname = f"frame_template{suffix}{'_white' if white else ''}.png"
|
||||||
|
frame_root = os.path.join(_ROOT, ".cache", "frames") if profile == "politics" else os.path.join(_ROOT, "assets")
|
||||||
|
frame = os.path.join(frame_root, fname)
|
||||||
os.makedirs(os.path.dirname(frame), exist_ok=True)
|
os.makedirs(os.path.dirname(frame), exist_ok=True)
|
||||||
make_frame(frame, top_bar=VIDEO_TOP, bottom_top=VIDEO_BOTTOM, band_color=band)
|
video_top = POLITICS_VIDEO_TOP if profile == "politics" else VIDEO_TOP
|
||||||
|
video_bottom = POLITICS_VIDEO_BOTTOM if profile == "politics" else VIDEO_BOTTOM
|
||||||
|
make_frame(frame, top_bar=video_top, bottom_top=video_bottom, band_color=band)
|
||||||
bg = None
|
bg = None
|
||||||
if white:
|
if white:
|
||||||
bg = os.path.join(_ROOT, "assets", "bg_white.png")
|
bg = os.path.join(_ROOT, "assets", "bg_white.png")
|
||||||
make_solid(bg, (255, 255, 255))
|
make_solid(bg, (255, 255, 255))
|
||||||
pos = dict(
|
pos = dict(
|
||||||
video_y=_ty((VIDEO_TOP + VIDEO_BOTTOM) / 2),
|
video_y=_ty((video_top + video_bottom) / 2),
|
||||||
title_top_y=_ty(TITLE_TOP_Y),
|
title_top_y=_ty(190 if profile == "politics" else TITLE_TOP_Y),
|
||||||
title_main_y=_ty(TITLE_MAIN_Y),
|
title_main_y=_ty(365 if profile == "politics" else TITLE_MAIN_Y),
|
||||||
caption_y=_ty(VIDEO_BOTTOM - CAPTION_GAP),
|
caption_y=_ty(video_bottom - CAPTION_GAP),
|
||||||
effect_y=_ty(VIDEO_TOP + EFFECT_GAP),
|
effect_y=_ty(video_top + EFFECT_GAP),
|
||||||
channel_y=_ty(VIDEO_BOTTOM + (CANVAS_H - VIDEO_BOTTOM) * CHANNEL_RATIO),
|
channel_y=_ty(video_bottom + (CANVAS_H - video_bottom) * CHANNEL_RATIO),
|
||||||
comment_top=COMMENT_TOP,
|
comment_top=video_bottom,
|
||||||
)
|
)
|
||||||
return frame, bg, pos
|
return frame, bg, pos
|
||||||
|
|
||||||
@ -620,6 +643,7 @@ async def paste_draft(
|
|||||||
cards_fixed: bool = False,
|
cards_fixed: bool = False,
|
||||||
card_cuts: Optional[List[int]] = None,
|
card_cuts: Optional[List[int]] = None,
|
||||||
bg_white: bool = False,
|
bg_white: bool = False,
|
||||||
|
content_profile: str = "entertainment",
|
||||||
) -> AsyncIterator[dict]:
|
) -> AsyncIterator[dict]:
|
||||||
"""붙여넣기 파이프라인 뒷부분: [장면분할] → 댓글 카드 → 드래프트 생성 → result.
|
"""붙여넣기 파이프라인 뒷부분: [장면분할] → 댓글 카드 → 드래프트 생성 → result.
|
||||||
|
|
||||||
@ -644,7 +668,7 @@ async def paste_draft(
|
|||||||
# ── draft ──
|
# ── draft ──
|
||||||
yield {"type": "step", "id": "draft", "status": "start"}
|
yield {"type": "step", "id": "draft", "status": "start"}
|
||||||
t = time.perf_counter()
|
t = time.perf_counter()
|
||||||
frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white)
|
frame, bg, pos = await asyncio.to_thread(_template_pos, bg_white, content_profile)
|
||||||
|
|
||||||
# 장면분할(선택): 화면 바뀌는 지점마다 세그먼트 추가 분할(자막 시간 불변)
|
# 장면분할(선택): 화면 바뀌는 지점마다 세그먼트 추가 분할(자막 시간 불변)
|
||||||
if scene_split:
|
if scene_split:
|
||||||
@ -679,6 +703,8 @@ async def paste_draft(
|
|||||||
channel=channel or None, video_scale=video_scale,
|
channel=channel or None, video_scale=video_scale,
|
||||||
flip_horizontal=flip_horizontal, effect_captions=eff_caps,
|
flip_horizontal=flip_horizontal, effect_captions=eff_caps,
|
||||||
comment_cards=cards, bg_white=bg_white, **pos,
|
comment_cards=cards, bg_white=bg_white, **pos,
|
||||||
|
logo_image=(os.path.join(_ROOT, "assets", "politics_factlab_logo.png")
|
||||||
|
if content_profile == "politics" else None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await _floor(t)
|
await _floor(t)
|
||||||
|
|||||||
@ -93,10 +93,12 @@ def _extract_json_str(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def parse_candidates(text: str, *, src: str = "Step 1 응답") -> List[Dict]:
|
def parse_candidates(text: str, *, src: str = "Step 1 응답") -> List[Dict]:
|
||||||
"""`{"candidates":[{id,start_time,end_time,reason}, …]}` → [{id,start,end,reason}] (초).
|
"""`{"candidates":[{id,start_time,end_time,reason}, …]}` → [{id,start,end,reason,title_top,title_main}] (초).
|
||||||
|
|
||||||
Gemini Step1 응답과 사용자가 직접 붙여넣는 구간 JSON이 **같은 형식**이라 둘이 공유한다.
|
Gemini Step1 응답과 사용자가 직접 붙여넣는 구간 JSON이 **같은 형식**이라 둘이 공유한다.
|
||||||
코드펜스(```)·앞뒤 잡텍스트는 `_extract_json_str`이 걷어낸다.
|
코드펜스(```)·앞뒤 잡텍스트는 `_extract_json_str`이 걷어낸다.
|
||||||
|
`title_top`/`title_main`은 선택 — 구간 JSON에 있으면 검토 화면 제목 입력칸에
|
||||||
|
미리 채워진다(Step1 응답에는 없으므로 빈 문자열).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = json.loads(_extract_json_str(text), strict=False)
|
data = json.loads(_extract_json_str(text), strict=False)
|
||||||
@ -116,7 +118,16 @@ def parse_candidates(text: str, *, src: str = "Step 1 응답") -> List[Dict]:
|
|||||||
continue
|
continue
|
||||||
if e > s:
|
if e > s:
|
||||||
out.append({"id": int(c.get("id") or i), "start": s, "end": e,
|
out.append({"id": int(c.get("id") or i), "start": s, "end": e,
|
||||||
"reason": str(c.get("reason") or "").strip()})
|
"reason": str(c.get("reason") or "").strip(),
|
||||||
|
"title_top": str(c.get("title_top") or "").strip(),
|
||||||
|
"title_main": str(c.get("title_main") or "").strip(),
|
||||||
|
# 정치 구간 JSON의 표시용 메타데이터. 일반 모드에는 빈 값이라 하위호환.
|
||||||
|
"speaker": str(c.get("speaker") or "").strip(),
|
||||||
|
"target": str(c.get("target") or "").strip(),
|
||||||
|
"issue": str(c.get("issue") or "").strip(),
|
||||||
|
"viral_type": str(c.get("viral_type") or "").strip(),
|
||||||
|
"source_channel": str(c.get("source_channel") or
|
||||||
|
c.get("channel") or "").strip()})
|
||||||
if not out:
|
if not out:
|
||||||
raise RuntimeError(f"{src}: 유효한 구간이 하나도 없습니다.")
|
raise RuntimeError(f"{src}: 유효한 구간이 하나도 없습니다.")
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -6,11 +6,11 @@
|
|||||||
# - Node.js 또는 deno (yt-dlp 유튜브 추출용 JS 런타임)
|
# - Node.js 또는 deno (yt-dlp 유튜브 추출용 JS 런타임)
|
||||||
# - CapCut (드래프트 열기 + 코트라 볼드체 폰트 캐시)
|
# - CapCut (드래프트 열기 + 코트라 볼드체 폰트 캐시)
|
||||||
|
|
||||||
fastapi
|
fastapi==0.115.14
|
||||||
uvicorn
|
uvicorn==0.35.0
|
||||||
python-multipart
|
python-multipart==0.0.20
|
||||||
pyCapCut
|
pyCapCut==0.0.3
|
||||||
Pillow
|
Pillow==10.4.0
|
||||||
pymediainfo
|
pymediainfo==7.0.1
|
||||||
yt-dlp
|
yt-dlp==2026.7.4
|
||||||
faster-whisper # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요)
|
faster-whisper==1.2.1 # 파일/유튜브 구간 탭의 자막 받아쓰기용(붙여넣기 탭만 쓰면 불필요)
|
||||||
|
|||||||
296
server/app.py
296
server/app.py
@ -12,6 +12,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@ -43,25 +44,79 @@ os.makedirs(COMMENTS_DIR, exist_ok=True)
|
|||||||
app = FastAPI(title="캡컷 에이전트")
|
app = FastAPI(title="캡컷 에이전트")
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
|
STATE_TTL_SECONDS = int(os.getenv("CAPCUT_STATE_TTL_SECONDS", "86400"))
|
||||||
|
UPLOAD_TTL_SECONDS = int(os.getenv("CAPCUT_UPLOAD_TTL_SECONDS", "604800"))
|
||||||
|
MAX_UPLOAD_BYTES = int(os.getenv("CAPCUT_MAX_UPLOAD_BYTES", str(4 * 1024**3)))
|
||||||
|
UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class _ExpiringStore(dict):
|
||||||
|
"""기존 dict 인터페이스를 유지하면서 오래된 작업 상태를 지우는 메모리 저장소."""
|
||||||
|
|
||||||
|
def __init__(self, ttl: int):
|
||||||
|
super().__init__()
|
||||||
|
self.ttl = ttl
|
||||||
|
self._touched: dict[str, float] = {}
|
||||||
|
|
||||||
|
def _purge(self) -> None:
|
||||||
|
cutoff = time.time() - self.ttl
|
||||||
|
for key, touched in list(self._touched.items()):
|
||||||
|
if touched < cutoff:
|
||||||
|
super().pop(key, None)
|
||||||
|
self._touched.pop(key, None)
|
||||||
|
|
||||||
|
def __setitem__(self, key, value) -> None:
|
||||||
|
self._purge()
|
||||||
|
super().__setitem__(key, value)
|
||||||
|
self._touched[key] = time.time()
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
self._purge()
|
||||||
|
value = super().get(key, default)
|
||||||
|
if key in self:
|
||||||
|
self._touched[key] = time.time()
|
||||||
|
return value
|
||||||
|
|
||||||
|
def pop(self, key, default=None):
|
||||||
|
self._touched.pop(key, None)
|
||||||
|
return super().pop(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_uploads() -> None:
|
||||||
|
"""참조되지 않고 보존 기간이 지난 업로드와 중단된 임시 파일을 정리한다."""
|
||||||
|
cutoff = time.time() - UPLOAD_TTL_SECONDS
|
||||||
|
if isinstance(JOBS, _ExpiringStore):
|
||||||
|
JOBS._purge()
|
||||||
|
active = {os.path.abspath(j["path"]) for j in JOBS.values() if j.get("path")}
|
||||||
|
for entry in os.scandir(UPLOAD_DIR):
|
||||||
|
if not entry.is_file() or os.path.abspath(entry.path) in active:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if entry.stat().st_mtime < cutoff:
|
||||||
|
os.remove(entry.path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# job_id(content hash) → {path, draft_name, title_top, title_main, channel}
|
# job_id(content hash) → {path, draft_name, title_top, title_main, channel}
|
||||||
JOBS: dict[str, dict] = {}
|
JOBS: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)
|
||||||
|
|
||||||
# analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음)
|
# analysis_id → {"url": …} (분석은 SSE 1회성 — 결과는 브라우저가 들고 있음)
|
||||||
ANALYSES: dict[str, dict] = {}
|
ANALYSES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)
|
||||||
|
|
||||||
# 📋 붙여넣기 탭(새 흐름) — analysis_id → {"state", "places", "orig", "payload"}
|
# 📋 붙여넣기 탭(새 흐름) — analysis_id → {"state", "places", "orig", "payload"}
|
||||||
# /paste/stream 이 채우고 /paste/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
|
# /paste/stream 이 채우고 /paste/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
|
||||||
PSTATES: dict[str, dict] = {}
|
PSTATES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)
|
||||||
|
|
||||||
# ▶ 유튜브 구간 탭(새 흐름) — analysis_id → {"state"}
|
# ▶ 유튜브 구간 탭(새 흐름) — analysis_id → {"state"}
|
||||||
# /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
|
# /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
|
||||||
YSTATES: dict[str, dict] = {}
|
YSTATES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)
|
||||||
|
|
||||||
# 🤖 자동 탭 2단계(준비) — prepare_id → {"aid", "ids"}
|
# 🤖 자동 탭 2단계(준비) — prepare_id → {"aid", "ids"}
|
||||||
# /auto/prepare(POST) 가 채우고 /auto/prepare/{pid}(SSE) 가 꺼내 쓴다.
|
# /auto/prepare(POST) 가 채우고 /auto/prepare/{pid}(SSE) 가 꺼내 쓴다.
|
||||||
# 준비된 개별 편집안의 다운로드·받아쓰기 상태는 PSTATES[f"{aid}:{id}"] 에 담긴다
|
# 준비된 개별 편집안의 다운로드·받아쓰기 상태는 PSTATES[f"{aid}:{id}"] 에 담긴다
|
||||||
# (📋 붙여넣기 탭과 같은 저장소를 공유 — /auto/build 가 그 값으로 paste_draft 만 돌린다).
|
# (📋 붙여넣기 탭과 같은 저장소를 공유 — /auto/build 가 그 값으로 paste_draft 만 돌린다).
|
||||||
PREPARES: dict[str, dict] = {}
|
PREPARES: dict[str, dict] = _ExpiringStore(STATE_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드")
|
_DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드")
|
||||||
@ -137,6 +192,89 @@ def _parse_ranges(raw: str) -> list[tuple[str, str]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _highlight_card_count(total: float) -> int:
|
||||||
|
return max(1, int(total // 3))
|
||||||
|
|
||||||
|
|
||||||
|
def _cuts_json(cuts) -> list[dict]:
|
||||||
|
return [{"start": s, "end": e, "bottom": b, "effect": f}
|
||||||
|
for s, e, b, f in cuts]
|
||||||
|
|
||||||
|
|
||||||
|
def _whole_highlight(candidate: dict, url: str, *, profile: str = "entertainment") -> dict:
|
||||||
|
"""후보 구간 전체를 컷 하나로 쓰는 자동 편집안으로 변환한다."""
|
||||||
|
total = candidate["end"] - candidate["start"]
|
||||||
|
return {
|
||||||
|
"id": candidate["id"], "start": candidate["start"], "end": candidate["end"],
|
||||||
|
"reason": candidate["reason"],
|
||||||
|
"paste": {"url": url,
|
||||||
|
"title_top": candidate.get("title_top") or "",
|
||||||
|
"title_main": candidate.get("title_main") or "",
|
||||||
|
# 정치 모드는 비워 둬야 paste_analyze가 URL의 실제 uploader를 가져온다.
|
||||||
|
"channel": "",
|
||||||
|
"cuts": [{"start": candidate["start"], "end": candidate["end"],
|
||||||
|
"bottom": "", "effect": ""}]},
|
||||||
|
"titles": [], "editable_title": True,
|
||||||
|
"total": round(total, 1),
|
||||||
|
"need": 0 if profile == "politics" else _highlight_card_count(total),
|
||||||
|
"content_profile": profile,
|
||||||
|
"issue": candidate.get("issue") or "",
|
||||||
|
"viral_type": candidate.get("viral_type") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _plan_highlight(url: str, index: int, candidate: dict) -> dict:
|
||||||
|
"""Gemini 할당량 충돌 시 요청별 시차를 두고 편집안을 최대 세 번 시도한다."""
|
||||||
|
for attempt in range(1, 4):
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
autoplan.edit_plan, url, candidate["start"], candidate["end"])
|
||||||
|
except GeminiQuotaError:
|
||||||
|
if attempt == 3:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(15 * attempt + index * 5)
|
||||||
|
raise RuntimeError("편집안 생성 재시도 횟수를 초과했습니다.")
|
||||||
|
|
||||||
|
|
||||||
|
class _UploadTooLarge(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_upload(file: UploadFile) -> tuple[str, str]:
|
||||||
|
"""업로드를 메모리에 적재하지 않고 저장하고 (content hash, 경로)를 반환한다."""
|
||||||
|
_cleanup_uploads()
|
||||||
|
digest = hashlib.sha1()
|
||||||
|
size = 0
|
||||||
|
tmp_path = ""
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(dir=UPLOAD_DIR, prefix="upload-", suffix=".part",
|
||||||
|
delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
while chunk := await file.read(UPLOAD_CHUNK_BYTES):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_UPLOAD_BYTES:
|
||||||
|
raise _UploadTooLarge
|
||||||
|
digest.update(chunk)
|
||||||
|
tmp.write(chunk)
|
||||||
|
|
||||||
|
content_hash = digest.hexdigest()[:12]
|
||||||
|
ext = os.path.splitext(file.filename or "")[1].lower() or ".mp4"
|
||||||
|
path = os.path.join(UPLOAD_DIR, content_hash + ext)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
os.utime(path, None)
|
||||||
|
else:
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
return content_hash, path
|
||||||
|
except Exception:
|
||||||
|
if tmp_path and os.path.exists(tmp_path):
|
||||||
|
try:
|
||||||
|
os.remove(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@app.post("/upload")
|
@app.post("/upload")
|
||||||
async def upload(
|
async def upload(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@ -148,15 +286,13 @@ 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()
|
try:
|
||||||
# content hash → 같은 영상 재업로드 시 캐시/멱등 (mtime 아님)
|
h, path = await _save_upload(file)
|
||||||
h = hashlib.sha1(data).hexdigest()[:12]
|
except _UploadTooLarge:
|
||||||
ext = os.path.splitext(file.filename or "")[1].lower() or ".mp4"
|
gib = MAX_UPLOAD_BYTES / 1024**3
|
||||||
path = os.path.join(UPLOAD_DIR, h + ext)
|
return JSONResponse({"error": f"업로드 파일이 제한({gib:g}GB)을 넘습니다."}, 413)
|
||||||
if not os.path.exists(path):
|
|
||||||
with open(path, "wb") as f:
|
|
||||||
f.write(data)
|
|
||||||
base = os.path.splitext(os.path.basename(file.filename or "video"))[0]
|
base = os.path.splitext(os.path.basename(file.filename or "video"))[0]
|
||||||
safe = "".join(c for c in base if c.isalnum() or c in (" ", "_", "-")).strip() or "video"
|
safe = "".join(c for c in base if c.isalnum() or c in (" ", "_", "-")).strip() or "video"
|
||||||
JOBS[h] = {
|
JOBS[h] = {
|
||||||
@ -164,6 +300,7 @@ 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"]})
|
||||||
|
|
||||||
@ -220,6 +357,7 @@ async def stream(job_id: str) -> StreamingResponse:
|
|||||||
cards_fixed=job.get("cards_fixed", False),
|
cards_fixed=job.get("cards_fixed", False),
|
||||||
card_cuts=job.get("card_cuts") or None,
|
card_cuts=job.get("card_cuts") or None,
|
||||||
bg_white=job.get("bg_white", False),
|
bg_white=job.get("bg_white", False),
|
||||||
|
content_profile=job.get("content_profile", "entertainment"),
|
||||||
)
|
)
|
||||||
elif job.get("bg_state"): # ▶ 유튜브 구간 탭(새 흐름) — 이미 분석된 상태로 드래프트만
|
elif job.get("bg_state"): # ▶ 유튜브 구간 탭(새 흐름) — 이미 분석된 상태로 드래프트만
|
||||||
# paste_state 와 같은 이유로 draft 단독 manifest 를 여기서 새로 낸다.
|
# paste_state 와 같은 이유로 draft 단독 manifest 를 여기서 새로 낸다.
|
||||||
@ -262,6 +400,7 @@ 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)
|
||||||
@ -280,6 +419,7 @@ 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 에서.
|
||||||
|
|
||||||
@ -293,9 +433,11 @@ 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)
|
||||||
aid = hashlib.sha1(("yt|" + u + "|" + ranges).encode()).hexdigest()[:12]
|
rs = _truthy(remove_silence)
|
||||||
|
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}
|
"title_main": title_main, "channel": channel, "remove_silence": rs}
|
||||||
return JSONResponse({"analysis_id": aid})
|
return JSONResponse({"analysis_id": aid})
|
||||||
|
|
||||||
|
|
||||||
@ -314,8 +456,9 @@ 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) if s["id"] != "draft"] +
|
[s for s in bg_steps(youtube, rs) 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))
|
||||||
@ -326,7 +469,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):
|
youtube=youtube, remove_silence=rs):
|
||||||
if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다
|
if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다
|
||||||
state = ev["state"]
|
state = ev["state"]
|
||||||
continue
|
continue
|
||||||
@ -357,8 +500,9 @@ 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 = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
|
||||||
# bg_analyze 의 무음 제거는 항상 켜져 있어 state["places"] 는 이미 압축 좌표다
|
# state["places"] 는 bg_analyze 가 이미 최종 타임라인 좌표로 만들어 둔 것이다
|
||||||
# (_remap_placements 를 여기서 또 부르면 두 번 압축된다 — 부르지 않는다).
|
# (무음 제거 켬 = 압축 좌표 / 끔 = raw_places 그대로). 여기서 _remap_placements 를
|
||||||
|
# 또 부르면 두 번 압축된다 — 부르지 않는다.
|
||||||
places = state["places"]
|
places = state["places"]
|
||||||
orig = state["ranges_sec"]
|
orig = state["ranges_sec"]
|
||||||
try:
|
try:
|
||||||
@ -449,7 +593,8 @@ 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))
|
||||||
@ -473,13 +618,14 @@ async def auto_analyze(url: str = Form(""), mode: str = Form("full"),
|
|||||||
mode: full = Step1+Step3 (AI 컷편집, 기본)
|
mode: full = Step1+Step3 (AI 컷편집, 기본)
|
||||||
whole = Step1만 — 구간 5개를 통짜로(컷 편집 없음)
|
whole = Step1만 — 구간 5개를 통짜로(컷 편집 없음)
|
||||||
paste = 오팔 JSON 여러 개 붙여넣기 — Gemini 안 씀
|
paste = 오팔 JSON 여러 개 붙여넣기 — Gemini 안 씀
|
||||||
|
politics = 정치 구간 JSON — 댓글·검토 없이 정치 레이아웃 생성
|
||||||
"""
|
"""
|
||||||
mode = mode if mode in ("full", "whole", "wpaste", "paste") else "full"
|
mode = mode if mode in ("full", "whole", "wpaste", "paste", "politics") else "full"
|
||||||
u = url.strip()
|
u = url.strip()
|
||||||
if mode == "paste":
|
if mode == "paste":
|
||||||
if not data.strip():
|
if not data.strip():
|
||||||
return JSONResponse({"error": "오팔 JSON을 붙여넣으세요."}, 400)
|
return JSONResponse({"error": "오팔 JSON을 붙여넣으세요."}, 400)
|
||||||
elif mode == "wpaste":
|
elif mode in ("wpaste", "politics"):
|
||||||
# 구간 JSON 붙여넣기 — Gemini 안 씀. URL은 JSON에 없으므로 입력칸이 필수.
|
# 구간 JSON 붙여넣기 — Gemini 안 씀. URL은 JSON에 없으므로 입력칸이 필수.
|
||||||
if not data.strip():
|
if not data.strip():
|
||||||
return JSONResponse({"error": "구간 JSON을 붙여넣으세요."}, 400)
|
return JSONResponse({"error": "구간 JSON을 붙여넣으세요."}, 400)
|
||||||
@ -517,29 +663,6 @@ async def auto_stream(aid: str) -> StreamingResponse:
|
|||||||
mode = a.get("mode", "full")
|
mode = a.get("mode", "full")
|
||||||
highlights: list[dict] = []
|
highlights: list[dict] = []
|
||||||
|
|
||||||
def _need(total: float) -> int:
|
|
||||||
return max(1, int(total // 3))
|
|
||||||
|
|
||||||
def _cuts_json(cuts) -> list[dict]:
|
|
||||||
return [{"start": s, "end": e, "bottom": b, "effect": f}
|
|
||||||
for s, e, b, f in cuts]
|
|
||||||
|
|
||||||
def _whole_hl(c: dict) -> dict:
|
|
||||||
"""구간 통짜 하이라이트 — 컷 편집 없이 구간 전체가 컷 1개.
|
|
||||||
제목은 비워 두고 `editable_title` 로 UI에서 직접 입력받는다.
|
|
||||||
whole(Gemini Step1) 과 wpaste(구간 JSON 붙여넣기) 가 공유."""
|
|
||||||
total = c["end"] - c["start"]
|
|
||||||
return {
|
|
||||||
"id": c["id"], "start": c["start"], "end": c["end"],
|
|
||||||
"reason": c["reason"],
|
|
||||||
"paste": {"url": url, "title_top": "", "title_main": "",
|
|
||||||
"channel": "",
|
|
||||||
"cuts": [{"start": c["start"], "end": c["end"],
|
|
||||||
"bottom": "", "effect": ""}]},
|
|
||||||
"titles": [], "editable_title": True,
|
|
||||||
"total": round(total, 1), "need": _need(total),
|
|
||||||
}
|
|
||||||
|
|
||||||
if mode == "paste":
|
if mode == "paste":
|
||||||
# ── 오팔 JSON 여러 개 — Gemini 안 씀 ──
|
# ── 오팔 JSON 여러 개 — Gemini 안 씀 ──
|
||||||
yield _sse({"type": "manifest", "steps": [
|
yield _sse({"type": "manifest", "steps": [
|
||||||
@ -601,7 +724,8 @@ async def auto_stream(aid: str) -> StreamingResponse:
|
|||||||
"paste": {"url": p["url"], "title_top": p["title_top"],
|
"paste": {"url": p["url"], "title_top": p["title_top"],
|
||||||
"title_main": p["title_main"], "channel": p["channel"],
|
"title_main": p["title_main"], "channel": p["channel"],
|
||||||
"cuts": _cuts_json(p["cuts"])},
|
"cuts": _cuts_json(p["cuts"])},
|
||||||
"titles": titles, "total": round(total, 1), "need": _need(total),
|
"titles": titles, "total": round(total, 1),
|
||||||
|
"need": _highlight_card_count(total),
|
||||||
})
|
})
|
||||||
if not highlights:
|
if not highlights:
|
||||||
yield _sse({"type": "error",
|
yield _sse({"type": "error",
|
||||||
@ -610,7 +734,7 @@ async def auto_stream(aid: str) -> StreamingResponse:
|
|||||||
url = best_url
|
url = best_url
|
||||||
yield _sse({"type": "step", "id": "parse", "status": "done",
|
yield _sse({"type": "step", "id": "parse", "status": "done",
|
||||||
"detail": f"{len(highlights)}개 편집안"})
|
"detail": f"{len(highlights)}개 편집안"})
|
||||||
elif mode == "wpaste":
|
elif mode in ("wpaste", "politics"):
|
||||||
# ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ──
|
# ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ──
|
||||||
yield _sse({"type": "manifest", "steps": [
|
yield _sse({"type": "manifest", "steps": [
|
||||||
{"id": "parse", "label": "구간 JSON 파싱"},
|
{"id": "parse", "label": "구간 JSON 파싱"},
|
||||||
@ -621,7 +745,8 @@ async def auto_stream(aid: str) -> StreamingResponse:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
yield _sse({"type": "error", "message": str(exc)})
|
yield _sse({"type": "error", "message": str(exc)})
|
||||||
return
|
return
|
||||||
highlights.extend(_whole_hl(c) for c in cands)
|
profile = "politics" if mode == "politics" else "entertainment"
|
||||||
|
highlights.extend(_whole_highlight(c, url, profile=profile) for c in cands)
|
||||||
yield _sse({"type": "step", "id": "parse", "status": "done",
|
yield _sse({"type": "step", "id": "parse", "status": "done",
|
||||||
"detail": f"{len(highlights)}개 구간"})
|
"detail": f"{len(highlights)}개 구간"})
|
||||||
else:
|
else:
|
||||||
@ -643,23 +768,12 @@ async def auto_stream(aid: str) -> StreamingResponse:
|
|||||||
|
|
||||||
if mode == "whole":
|
if mode == "whole":
|
||||||
# ── 구간 통짜 — 컷 편집 없이 구간 전체가 컷 1개 ──
|
# ── 구간 통짜 — 컷 편집 없이 구간 전체가 컷 1개 ──
|
||||||
highlights.extend(_whole_hl(c) for c in cands)
|
highlights.extend(_whole_highlight(c, url) for c in cands)
|
||||||
else:
|
else:
|
||||||
# ── Step 3 (동시) ──
|
# ── Step 3 (동시) ──
|
||||||
yield _sse({"type": "step", "id": "step3", "status": "start"})
|
yield _sse({"type": "step", "id": "step3", "status": "start"})
|
||||||
|
|
||||||
async def _plan_one(i: int, c: dict):
|
plan_tasks = [asyncio.create_task(_plan_highlight(url, i, c))
|
||||||
# 429는 시차를 두고 재시도(동시 재충돌 방지: 시도×15s + 구간×5s)
|
|
||||||
for attempt in range(1, 4):
|
|
||||||
try:
|
|
||||||
return await asyncio.to_thread(
|
|
||||||
autoplan.edit_plan, url, c["start"], c["end"])
|
|
||||||
except GeminiQuotaError:
|
|
||||||
if attempt == 3:
|
|
||||||
raise
|
|
||||||
await asyncio.sleep(15 * attempt + i * 5)
|
|
||||||
|
|
||||||
plan_tasks = [asyncio.create_task(_plan_one(i, c))
|
|
||||||
for i, c in enumerate(cands)]
|
for i, c in enumerate(cands)]
|
||||||
for c, t in zip(cands, plan_tasks):
|
for c, t in zip(cands, plan_tasks):
|
||||||
hl = {"id": c["id"], "start": c["start"], "end": c["end"],
|
hl = {"id": c["id"], "start": c["start"], "end": c["end"],
|
||||||
@ -675,7 +789,7 @@ async def auto_stream(aid: str) -> StreamingResponse:
|
|||||||
"cuts": _cuts_json(p["cuts"])},
|
"cuts": _cuts_json(p["cuts"])},
|
||||||
"titles": r["titles"],
|
"titles": r["titles"],
|
||||||
"total": round(total, 1),
|
"total": round(total, 1),
|
||||||
"need": _need(total),
|
"need": _highlight_card_count(total),
|
||||||
})
|
})
|
||||||
if r["time_note"]:
|
if r["time_note"]:
|
||||||
yield _sse({"type": "log",
|
yield _sse({"type": "log",
|
||||||
@ -720,10 +834,13 @@ 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(...)) -> JSONResponse:
|
async def auto_prepare(aid: str = Form(...), ids: str = Form(...),
|
||||||
|
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"):
|
||||||
@ -734,8 +851,11 @@ async def auto_prepare(aid: str = Form(...), ids: str = Form(...)) -> JSONRespon
|
|||||||
raise ValueError
|
raise ValueError
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
return JSONResponse({"error": "준비할 ID 목록이 올바르지 않습니다."}, 400)
|
return JSONResponse({"error": "준비할 ID 목록이 올바르지 않습니다."}, 400)
|
||||||
pid = hashlib.sha1((aid + "|" + ids).encode()).hexdigest()[:12]
|
rs = _truthy(remove_silence)
|
||||||
PREPARES[pid] = {"aid": aid, "ids": id_list}
|
pid = hashlib.sha1((aid + "|" + ids + "|rs" + ("1" if rs else "0")).encode()).hexdigest()[:12]
|
||||||
|
PREPARES[pid] = {"aid": aid, "ids": id_list, "remove_silence": rs,
|
||||||
|
"content_profile": "politics" if a.get("mode") == "politics"
|
||||||
|
else "entertainment"}
|
||||||
return JSONResponse({"prepare_id": pid})
|
return JSONResponse({"prepare_id": pid})
|
||||||
|
|
||||||
|
|
||||||
@ -764,15 +884,17 @@ async def auto_prepare_stream(pid: str) -> StreamingResponse:
|
|||||||
yield _sse({"type": "error", "message": "준비할 편집안이 없습니다."})
|
yield _sse({"type": "error", "message": "준비할 편집안이 없습니다."})
|
||||||
return
|
return
|
||||||
url = a.get("url", "")
|
url = a.get("url", "")
|
||||||
|
politics = p.get("content_profile") == "politics"
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
yield _sse({"type": "manifest", "steps": [
|
steps = [{"id": "prepare", "label": "ID별 순차 준비 (다운로드·받아쓰기)"}]
|
||||||
{"id": "comments", "label": "댓글 수집 (h-lab)"},
|
if not politics:
|
||||||
{"id": "prepare", "label": "ID별 순차 준비 (다운로드·받아쓰기)"},
|
steps.insert(0, {"id": "comments", "label": "댓글 수집 (h-lab)"})
|
||||||
]})
|
yield _sse({"type": "manifest", "steps": steps})
|
||||||
|
|
||||||
yield _sse({"type": "step", "id": "comments", "status": "start"})
|
|
||||||
comments: list[dict] = []
|
comments: list[dict] = []
|
||||||
|
if not politics:
|
||||||
|
yield _sse({"type": "step", "id": "comments", "status": "start"})
|
||||||
try:
|
try:
|
||||||
comments = await asyncio.to_thread(hlab.fetch_comments, url)
|
comments = await asyncio.to_thread(hlab.fetch_comments, url)
|
||||||
yield _sse({"type": "step", "id": "comments", "status": "done",
|
yield _sse({"type": "step", "id": "comments", "status": "done",
|
||||||
@ -800,7 +922,8 @@ 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=True, asr_bottom=True,
|
remove_silence=p.get("remove_silence", 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"]
|
||||||
@ -818,11 +941,20 @@ async def auto_prepare_stream(pid: str) -> StreamingResponse:
|
|||||||
warnings.append(f"ID {hid} 준비 실패 — 분석 상태를 만들지 못했습니다")
|
warnings.append(f"ID {hid} 준비 실패 — 분석 상태를 만들지 못했습니다")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if politics:
|
||||||
|
# paste_analyze가 다운로드 결과의 uploader를 "@채널명"으로 채운다.
|
||||||
|
# 하단 표시는 계정 멘션이 아니라 출처 표기이므로 접두사를 바꾼다.
|
||||||
|
actual_channel = str(state.get("channel") or "").strip().lstrip("@").strip()
|
||||||
|
state["channel"] = (f"출처 · {actual_channel}" if actual_channel else "출처 · 채널명 확인 필요")
|
||||||
|
|
||||||
# ⚠ 좌표계 둘: places = 압축 타임라인(카드·자막 추출용),
|
# ⚠ 좌표계 둘: places = 압축 타임라인(카드·자막 추출용),
|
||||||
# orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
|
# orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
|
||||||
places = state["card_places"]
|
places = state["card_places"]
|
||||||
orig = [(s, e) for s, e, _, _ in state["cuts"]]
|
orig = [(s, e) for s, e, _, _ in state["cuts"]]
|
||||||
all_ranges.extend(orig)
|
all_ranges.extend(orig)
|
||||||
|
if politics:
|
||||||
|
cuts, need = None, 0
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
cuts, need, ai_failed = await asyncio.to_thread(
|
cuts, need, ai_failed = await asyncio.to_thread(
|
||||||
recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
|
recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
|
||||||
@ -835,7 +967,7 @@ async def auto_prepare_stream(pid: str) -> StreamingResponse:
|
|||||||
f"({type(exc).__name__}: {exc})")
|
f"({type(exc).__name__}: {exc})")
|
||||||
|
|
||||||
PSTATES[f"{aid}:{hid}"] = {"state": state, "places": places, "orig": orig,
|
PSTATES[f"{aid}:{hid}"] = {"state": state, "places": places, "orig": orig,
|
||||||
"payload": payload}
|
"payload": payload, "content_profile": p["content_profile"]}
|
||||||
matched = hlab.match_ranges(comments, orig) if comments else []
|
matched = hlab.match_ranges(comments, orig) if comments else []
|
||||||
highlights_out.append({
|
highlights_out.append({
|
||||||
"id": hid, "cuts": cuts, "need": need,
|
"id": hid, "cuts": cuts, "need": need,
|
||||||
@ -934,8 +1066,20 @@ async def auto_build(
|
|||||||
if not st:
|
if not st:
|
||||||
return JSONResponse({"error": "준비 결과가 만료됐습니다. 다시 준비해 주세요."}, 404)
|
return JSONResponse({"error": "준비 결과가 만료됐습니다. 다시 준비해 주세요."}, 404)
|
||||||
state = st["state"]
|
state = st["state"]
|
||||||
state["title_top"] = title_top
|
# 정치 모드는 검토 화면이 없어 브라우저 상태가 초기화돼 빈 제목이 올 수 있다.
|
||||||
state["title_main"] = title_main
|
# 이 경우 준비 단계에 보관한 원래 JSON 제목을 복구해 자리표시자가 들어가지 않게 한다.
|
||||||
|
original = st.get("payload") or {}
|
||||||
|
state["title_top"] = title_top.strip() or str(original.get("title_top") or "").strip()
|
||||||
|
state["title_main"] = title_main.strip() or str(original.get("title_main") or "").strip()
|
||||||
|
# 자동 탭에서 만드는 모든 드래프트 이름은 화면 제목과 동일하게 맞춘다.
|
||||||
|
# Windows/CapCut 폴더명에 안전한 문자만 남기고 "title_top title_main" 형식을 유지한다.
|
||||||
|
display_name = " ".join(x for x in (state["title_top"], state["title_main"]) if x).strip()
|
||||||
|
safe_name = "".join(c for c in display_name
|
||||||
|
if c.isalnum() or c in (" ", "_", "-", ".")).strip()[:80]
|
||||||
|
if safe_name:
|
||||||
|
state["draft_name"] = safe_name
|
||||||
|
if st.get("content_profile") == "politics":
|
||||||
|
state["channel"] = str(original.get("channel") or state.get("channel") or "").strip()
|
||||||
|
|
||||||
# asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로
|
# asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로
|
||||||
# 되돌린다(/paste/build 와 동일 규칙 — state["bottom_caps"]는 /auto/prepare 가
|
# 되돌린다(/paste/build 와 동일 규칙 — state["bottom_caps"]는 /auto/prepare 가
|
||||||
@ -957,7 +1101,8 @@ async def auto_build(
|
|||||||
cut_map = []
|
cut_map = []
|
||||||
|
|
||||||
sig = (key + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
|
sig = (key + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
|
||||||
+ bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards)))
|
+ bg_white + "|" + cards_fixed + "|" + asr_bottom + "|"
|
||||||
|
+ st.get("content_profile", "entertainment") + "|" + str(len(cards)))
|
||||||
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
|
h = hashlib.sha1(sig.encode()).hexdigest()[:12]
|
||||||
cdir = comments_dir.strip()
|
cdir = comments_dir.strip()
|
||||||
if cards:
|
if cards:
|
||||||
@ -974,6 +1119,7 @@ async def auto_build(
|
|||||||
"paste_state": state, "card_cuts": cut_map,
|
"paste_state": state, "card_cuts": cut_map,
|
||||||
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
|
"video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
|
||||||
"comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
|
"comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
|
||||||
|
"content_profile": st.get("content_profile", "entertainment"),
|
||||||
}
|
}
|
||||||
return JSONResponse({"job_id": h})
|
return JSONResponse({"job_id": h})
|
||||||
|
|
||||||
|
|||||||
@ -526,26 +526,32 @@ 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 가 댓글 매칭을 위해 remove_silence=True·asr_bottom=True 로
|
// ⚠ 네 모드 전부 /auto/prepare 가 댓글 매칭을 위해 asr_bottom=True 로 고정해서 돈다.
|
||||||
// 고정해서 돈다(공통 옵션의 "무음 제거" 체크박스는 자동 탭에서 숨겨져 있다 — setMode() 참조).
|
// 무음 제거는 패널의 #autoRmsilence 체크박스(기본 켬)를 폼으로 넘겨 선택 가능 —
|
||||||
// "무음 제거는 아래 공통 옵션을 따른다"는 예전 문구가 이 사실과 어긋나 오해를 낳았었다.
|
// 댓글 매칭은 원본 시각(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개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. 제목(윗줄·아랫줄)은 댓글 선택 화면에서 ID별로 직접 씁니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
|
wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만들고, 제목은 JSON의 title_top·title_main을 그대로 씁니다. 1차 검토 없이 바로 다운로드·받아쓰기·댓글 매칭까지 진행되고 댓글 선택 화면으로 갑니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.",
|
||||||
paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).",
|
politics:"정치 구간 JSON을 그대로 사용합니다. 댓글 수집과 검토를 생략하고, 125% 전면 영상+확대 배경의 정치 레이아웃으로 만든 뒤 CapCut을 자동 실행합니다.",
|
||||||
|
paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.",
|
||||||
};
|
};
|
||||||
const PASTE_UI={
|
const PASTE_UI={
|
||||||
paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)",
|
paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)",
|
||||||
ph:'오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요.\n블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.',
|
ph:'오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요.\n블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.',
|
||||||
note:"(선택 — 비우면 JSON의 url 사용)"},
|
note:"(선택 — 비우면 JSON의 url 사용)"},
|
||||||
wpaste:{label:"구간 JSON 붙여넣기 (candidates 배열 — 구간 5개)",
|
wpaste:{label:"구간 JSON 붙여넣기 (candidates 배열 — 구간 5개 · title_top/title_main 선택)",
|
||||||
ph:'{\n "candidates": [\n {"id": 1, "start_time": "14:28", "end_time": "16:15", "reason": "구간 선정 이유"},\n … (총 5개)\n ]\n}',
|
ph:'{\n "candidates": [\n {"id": 1, "start_time": "14:28", "end_time": "16:15", "reason": "구간 선정 이유",\n "title_top": "상단 제목 (선택)", "title_main": "메인 제목 (선택)"},\n … (총 5개)\n ]\n}',
|
||||||
|
note:"(필수 — 구간 JSON에는 URL이 없습니다)"},
|
||||||
|
politics:{label:"정치 구간 JSON 붙여넣기 (speaker·target·title_top·title_main 사용)",
|
||||||
|
ph:'{\n "candidates": [\n {"id": 1, "start_time": "04:27", "end_time": "06:21",\n "speaker": "발언자", "target": "관련 기관",\n "title_top": "상단 제목", "title_main": "메인 제목"}\n ]\n}',
|
||||||
note:"(필수 — 구간 JSON에는 URL이 없습니다)"},
|
note:"(필수 — 구간 JSON에는 URL이 없습니다)"},
|
||||||
};
|
};
|
||||||
function applyMode(){
|
function applyMode(){
|
||||||
const m=curMode();
|
const m=curMode();
|
||||||
const p=PASTE_UI[m];
|
const p=PASTE_UI[m];
|
||||||
|
const politics=m==="politics";
|
||||||
$("#apasteField").style.display=p?"block":"none";
|
$("#apasteField").style.display=p?"block":"none";
|
||||||
if(p){
|
if(p){
|
||||||
$("#apasteLabel").textContent=p.label;
|
$("#apasteLabel").textContent=p.label;
|
||||||
@ -553,15 +559,25 @@ 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개)";
|
const runField=$("#autoRunField"); if(runField) runField.style.display=politics?"none":"block";
|
||||||
|
const silenceNote=$("#autoSilenceNote");
|
||||||
|
if(silenceNote) silenceNote.textContent=politics
|
||||||
|
?"받아쓰기(Whisper)는 적용합니다. 체크를 끄면 원본 구간 길이를 그대로 유지합니다."
|
||||||
|
:"받아쓰기(Whisper)·댓글 매칭은 항상 돌지만, 무음 제거는 꺼도 매칭이 어긋나지 않습니다.";
|
||||||
|
$("#autoGo").textContent=politics?"정치 숏폼 생성 → CapCut 자동 실행":
|
||||||
|
((p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)")+
|
||||||
|
(autoRunOn()?" → 영상까지 자동":""));
|
||||||
}
|
}
|
||||||
|
/* "끝까지 진행" — 검토 화면 두 곳(1차 제목 선택 · 2차 댓글 선택)에서 멈추지 않고
|
||||||
|
기본값·추천 그대로 다음 단계로 넘어간다. 화면 편의 기능이라 서버는 이 상태를 모른다. */
|
||||||
|
function autoRunOn(){const el=$("#autoAutoRun");return !!(el&&el.checked);}
|
||||||
|
|
||||||
/* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */
|
/* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */
|
||||||
async function analyze(){
|
async function analyze(){
|
||||||
const m=curMode();
|
const m=curMode();
|
||||||
const url=$("#autoUrl").value.trim();
|
const url=$("#autoUrl").value.trim();
|
||||||
if(m!=="paste"&&!url){
|
if(m!=="paste"&&!url){
|
||||||
alert(m==="wpaste"?"유튜브 URL을 입력하세요. (구간 JSON에는 URL이 없습니다)"
|
alert((m==="wpaste"||m==="politics")?"유튜브 URL을 입력하세요. (구간 JSON에는 URL이 없습니다)"
|
||||||
:"유튜브 URL을 입력하세요.");return;}
|
:"유튜브 URL을 입력하세요.");return;}
|
||||||
if(PASTE_UI[m]&&!$("#apaste").value.trim()){
|
if(PASTE_UI[m]&&!$("#apaste").value.trim()){
|
||||||
alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;}
|
alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;}
|
||||||
@ -591,7 +607,21 @@ 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==="politics"){
|
||||||
|
TITLE_PICKS={};
|
||||||
|
for(const h of A.highlights) if(!h.error)
|
||||||
|
TITLE_PICKS[h.id]={top:h.paste.title_top||"",main:h.paste.title_main||""};
|
||||||
|
alog("정치 모드 — 댓글과 검토를 생략하고 바로 생성 준비를 시작합니다.");
|
||||||
|
prepareAll();
|
||||||
|
}else 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("연결이 끊겼습니다.");};
|
||||||
@ -654,6 +684,11 @@ function onResult(ev){
|
|||||||
(t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
|
(t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
|
||||||
}
|
}
|
||||||
card.innerHTML=html;
|
card.innerHTML=html;
|
||||||
|
if(hl.editable_title){ // 구간 JSON의 title_top/title_main 미리 채움 (.value라 따옴표 이스케이프 불필요)
|
||||||
|
const ti=card.querySelector("#hltop-"+hl.id),mi=card.querySelector("#hlmain-"+hl.id);
|
||||||
|
if(ti) ti.value=hl.paste.title_top||"";
|
||||||
|
if(mi) mi.value=hl.paste.title_main||"";
|
||||||
|
}
|
||||||
card.dataset.titles=JSON.stringify(opts);
|
card.dataset.titles=JSON.stringify(opts);
|
||||||
card.appendChild(cutsSection(hl)); // 영상 편집안(컷 목록·JSON) — 기본 접힘
|
card.appendChild(cutsSection(hl)); // 영상 편집안(컷 목록·JSON) — 기본 접힘
|
||||||
R.appendChild(card);
|
R.appendChild(card);
|
||||||
@ -689,7 +724,7 @@ async function prepareAll(){
|
|||||||
if(!A) return;
|
if(!A) return;
|
||||||
const ids=liveIds();
|
const ids=liveIds();
|
||||||
if(!ids.length){alert("준비할 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;}
|
if(!ids.length){alert("준비할 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;}
|
||||||
TITLE_PICKS=collectTitlePicks();
|
if(curMode()!=="politics") TITLE_PICKS=collectTitlePicks();
|
||||||
const btn=$("#autoPrepGo");
|
const btn=$("#autoPrepGo");
|
||||||
btn.disabled=true;btn.textContent="준비 중…";
|
btn.disabled=true;btn.textContent="준비 중…";
|
||||||
$("#autoPrepSteps").innerHTML="";$("#autoPrepLog").innerHTML="";
|
$("#autoPrepSteps").innerHTML="";$("#autoPrepLog").innerHTML="";
|
||||||
@ -702,6 +737,8 @@ 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);
|
||||||
@ -762,6 +799,18 @@ function clearOtherPanels(exceptId){
|
|||||||
function onPrepareResult(ev){
|
function onPrepareResult(ev){
|
||||||
P=ev;byIdx={};sel={};selCut={};curId=null;
|
P=ev;byIdx={};sel={};selCut={};curId=null;
|
||||||
clearOtherPanels("auto");
|
clearOtherPanels("auto");
|
||||||
|
if(curMode()==="politics"){
|
||||||
|
(ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w));
|
||||||
|
$("#autoPickReview").innerHTML="";
|
||||||
|
$("#autoReview").innerHTML="";
|
||||||
|
if(!ev.highlights||!ev.highlights.length){
|
||||||
|
prepLog("⚠️ 준비된 정치 편집안이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prepLog("정치 모드 — 준비 완료, 댓글 카드 없이 드래프트를 생성합니다.");
|
||||||
|
setTimeout(()=>buildPolitics(ev.highlights),0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
|
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
|
||||||
(ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w));
|
(ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w));
|
||||||
const R=$("#autoReview");R.innerHTML="";
|
const R=$("#autoReview");R.innerHTML="";
|
||||||
@ -815,6 +864,38 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPolitics(hls){
|
||||||
|
boardInit(hls);
|
||||||
|
let ok=0,fail=0;
|
||||||
|
for(const hl of hls){
|
||||||
|
boardSet(hl.id,"🔄 진행","정치 레이아웃 생성 중…","active");
|
||||||
|
try{
|
||||||
|
const pick=TITLE_PICKS[hl.id]||{top:"",main:""};
|
||||||
|
const fd=new FormData();
|
||||||
|
fd.append("aid",AUTO_AID); fd.append("id",String(hl.id));
|
||||||
|
fd.append("title_top",pick.top||""); fd.append("title_main",pick.main||"");
|
||||||
|
fd.append("video_scale","160");
|
||||||
|
fd.append("flip",$("#flip").checked?"1":"");
|
||||||
|
fd.append("scene",$("#scene").checked?"1":"");
|
||||||
|
fd.append("bg_white",""); fd.append("asr_bottom","1");
|
||||||
|
const res=await(await fetch("/auto/build",{method:"POST",body:fd})).json();
|
||||||
|
if(res.error) throw new Error(res.error);
|
||||||
|
const r=await streamJob(res.job_id,hl.id);
|
||||||
|
boardSet(hl.id,"✅ 완료",(r&&r.draft_name)||"",""); ok++;
|
||||||
|
}catch(e){boardSet(hl.id,"❌ 실패",String(e.message||e),"err");fail++;}
|
||||||
|
}
|
||||||
|
const s=$("#autoSummary");s.style.display="block";
|
||||||
|
s.textContent=ok+"개 성공"+(fail?", "+fail+"개 실패":"")+" — 정치 레이아웃 생성 완료";
|
||||||
|
if(ok) fetch("/open-capcut",{method:"POST"});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 캡처 + 순차 빌드 ── */
|
/* ── 캡처 + 순차 빌드 ── */
|
||||||
@ -1065,6 +1146,9 @@ 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;
|
||||||
@ -1316,6 +1400,7 @@ 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);
|
||||||
@ -1327,12 +1412,15 @@ document.addEventListener("DOMContentLoaded",()=>{
|
|||||||
if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll);
|
if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── 옵션 기억 — 배경 흰색 체크를 localStorage 에 저장, 다음 방문에도 유지.
|
/* ── 옵션 기억 — 체크 상태를 localStorage 에 저장, 다음 방문에도 유지.
|
||||||
서버는 이 상태를 모른다(폼 전송 값만 봄) — 순수 화면 편의 기능. */
|
서버는 이 상태를 모른다(폼 전송 값만 봄) — 순수 화면 편의 기능. */
|
||||||
(function(){
|
(function(){
|
||||||
const bw=$("#bgwhite"); if(!bw) return;
|
for(const [id,key] of [["#bgwhite","opt_bgwhite"],["#autoRmsilence","opt_auto_rmsilence"],
|
||||||
const saved=localStorage.getItem("opt_bgwhite");
|
["#autoAutoRun","opt_auto_autorun"]]){
|
||||||
if(saved!==null) bw.checked=saved==="1";
|
const el=$(id); if(!el) continue;
|
||||||
bw.addEventListener("change",()=>localStorage.setItem("opt_bgwhite",bw.checked?"1":"0"));
|
const saved=localStorage.getItem(key);
|
||||||
|
if(saved!==null) el.checked=saved==="1";
|
||||||
|
el.addEventListener("change",()=>localStorage.setItem(key,el.checked?"1":"0"));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -444,12 +444,14 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>방식</label>
|
<label>방식</label>
|
||||||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
<label class="modeopt"><input type="radio" name="amode" value="full" checked>
|
<label class="modeopt"><input type="radio" name="amode" value="full">
|
||||||
<span class="modetitle">🤖 AI 컷편집</span><span class="modedesc">Step1+3 · 45~60초 숏폼</span></label>
|
<span class="modetitle">🤖 AI 컷편집</span><span class="modedesc">Step1+3 · 45~60초 숏폼</span></label>
|
||||||
<label class="modeopt"><input type="radio" name="amode" value="whole">
|
<label class="modeopt"><input type="radio" name="amode" value="whole">
|
||||||
<span class="modetitle">⏩ 구간 통짜</span><span class="modedesc">Step1만 · 구간 통으로</span></label>
|
<span class="modetitle">⏩ 구간 통짜</span><span class="modedesc">Step1만 · 구간 통으로</span></label>
|
||||||
<label class="modeopt"><input type="radio" name="amode" value="wpaste">
|
<label class="modeopt"><input type="radio" name="amode" value="wpaste" checked>
|
||||||
<span class="modetitle">📐 구간 JSON</span><span class="modedesc">구간 5개 붙여넣기 · 통짜</span></label>
|
<span class="modetitle">📐 구간 JSON</span><span class="modedesc">구간 5개 붙여넣기 · 통짜</span></label>
|
||||||
|
<label class="modeopt"><input type="radio" name="amode" value="politics">
|
||||||
|
<span class="modetitle">🏛 정치 구간 JSON</span><span class="modedesc">댓글·검토 없이 바로 생성</span></label>
|
||||||
<label class="modeopt"><input type="radio" name="amode" value="paste">
|
<label class="modeopt"><input type="radio" name="amode" value="paste">
|
||||||
<span class="modetitle">📋 오팔 JSON</span><span class="modedesc">Gemini 안 씀 · 붙여넣기</span></label>
|
<span class="modetitle">📋 오팔 JSON</span><span class="modedesc">Gemini 안 씀 · 붙여넣기</span></label>
|
||||||
</div>
|
</div>
|
||||||
@ -464,6 +466,18 @@
|
|||||||
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개를 순서대로 전부 붙여넣으세요. 블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.'></textarea>
|
placeholder='오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요. 블록 ②·③ 텍스트가 섞여 들어와도 자동으로 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" id="autoSilenceNote" style="margin-top:2px;">받아쓰기(Whisper)·댓글 매칭은 항상 돌지만, 무음 제거는 꺼도 매칭이 어긋나지 않습니다.</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="autoRunField">
|
||||||
|
<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에서
|
||||||
@ -549,8 +563,9 @@
|
|||||||
</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" style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (붙여넣기: 컷 안의 무음까지 잘라냄)
|
<input type="checkbox" id="rmsilence" checked 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;">
|
||||||
@ -624,8 +639,9 @@ 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·/auto prepare
|
// 붙여넣기 탭은 댓글 매칭을 위해 무음 제거·받아쓰기가 항상 켜진다(/paste stream 이
|
||||||
// 가 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"));
|
||||||
@ -767,7 +783,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");}
|
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");}
|
||||||
|
|
||||||
runBtn.addEventListener("click",async()=>{
|
runBtn.addEventListener("click",async()=>{
|
||||||
window._runStart=Date.now(); // 시작~끝 총 시간 측정
|
window._runStart=Date.now(); // 시작~끝 총 시간 측정
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user