fix: Android 코덱 호환성 + 스톡영상 스크린녹화 필터링 + Gitea URL 업데이트

- video_assembler: yuv420p, profile high, level 4.0, movflags faststart 추가
- stock_fetcher: AI/UI 키워드 실사영상으로 변환, 스크린녹화 태그 차단
- CLAUDE.md: Gitea URL https://gitea.gru.farm/ 으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JOUNGWOOK KWON
2026-04-07 21:24:47 +09:00
parent 726c593e85
commit 29cdeb2adf
3 changed files with 57 additions and 2 deletions

View File

@@ -10,10 +10,10 @@
## 저장소 ## 저장소
- Git 서버: Gitea (자체 NAS 운영) - Git 서버: Gitea (자체 NAS 운영)
- Gitea URL: http://nas.gru.farm:3001 - Gitea URL: https://gitea.gru.farm/
- 계정: airkjw - 계정: airkjw
- 저장소: blog-writer - 저장소: blog-writer
- Remote: http://nas.gru.farm:3001/airkjw/blog-writer - Remote: https://gitea.gru.farm/airkjw/blog-writer
- 토큰: 8a8842a56866feab3a44b9f044491bf0dfc44963 - 토큰: 8a8842a56866feab3a44b9f044491bf0dfc44963
## NAS ## NAS

View File

@@ -25,6 +25,45 @@ BASE_DIR = Path(__file__).parent.parent.parent
PEXELS_VIDEO_URL = 'https://api.pexels.com/videos/search' PEXELS_VIDEO_URL = 'https://api.pexels.com/videos/search'
PIXABAY_VIDEO_URL = 'https://pixabay.com/api/videos/' PIXABAY_VIDEO_URL = 'https://pixabay.com/api/videos/'
# 스크린 녹화/UI/텍스트 영상 제외 키워드
_SCREEN_BLOCK_TAGS = {
'screen recording', 'screenshot', 'tutorial', 'demo', 'interface',
'typing', 'chatgpt', 'chatbot', 'website', 'browser', 'desktop',
'laptop screen', 'phone screen', 'app', 'software', 'code', 'coding',
'monitor', 'computer screen', 'ui', 'ux', 'dashboard',
}
# 검색어에서 제외할 키워드 (스크린 녹화 유발)
_SEARCH_EXCLUDE = {
'chatgpt', 'ai chat', 'gpt', 'openai', 'claude', 'gemini',
'software', 'app', 'website', 'browser', 'code',
}
def _sanitize_keyword(keyword: str) -> str:
"""스크린 녹화 유발 키워드를 자연 영상 키워드로 변환."""
kw_lower = keyword.lower()
for excl in _SEARCH_EXCLUDE:
if excl in kw_lower:
# AI/기술 키워드 → 실사 대체 키워드
replacements = {
'chatgpt': 'futuristic technology',
'ai chat': 'artificial intelligence robot',
'gpt': 'digital innovation',
'openai': 'technology innovation',
'claude': 'digital brain',
'gemini': 'space stars',
'software': 'digital technology',
'app': 'smartphone lifestyle',
'website': 'modern office',
'browser': 'modern workspace',
'code': 'digital network',
}
for k, v in replacements.items():
if k in kw_lower:
return v
return keyword
def _load_config() -> dict: def _load_config() -> dict:
cfg_path = BASE_DIR / 'config' / 'shorts_config.json' cfg_path = BASE_DIR / 'config' / 'shorts_config.json'
@@ -47,6 +86,8 @@ def _search_pexels(keyword: str, api_key: str, prefer_vertical: bool = True) ->
import urllib.parse import urllib.parse
import urllib.request import urllib.request
keyword = _sanitize_keyword(keyword)
params = urllib.parse.urlencode({ params = urllib.parse.urlencode({
'query': keyword, 'query': keyword,
'orientation': 'portrait' if prefer_vertical else 'landscape', 'orientation': 'portrait' if prefer_vertical else 'landscape',
@@ -90,6 +131,8 @@ def _search_pixabay(keyword: str, api_key: str, prefer_vertical: bool = True) ->
"""Pixabay Video API 검색 → [{url, width, height, duration}, ...] 반환.""" """Pixabay Video API 검색 → [{url, width, height, duration}, ...] 반환."""
import urllib.parse import urllib.parse
keyword = _sanitize_keyword(keyword)
params = urllib.parse.urlencode({ params = urllib.parse.urlencode({
'key': api_key, 'key': api_key,
'q': keyword, 'q': keyword,
@@ -105,6 +148,10 @@ def _search_pixabay(keyword: str, api_key: str, prefer_vertical: bool = True) ->
data = json.loads(resp.read()) data = json.loads(resp.read())
results = [] results = []
for hit in data.get('hits', []): for hit in data.get('hits', []):
# 태그 기반 스크린녹화/UI 영상 필터링
tags = hit.get('tags', '').lower()
if any(block in tags for block in _SCREEN_BLOCK_TAGS):
continue
videos = hit.get('videos', {}) videos = hit.get('videos', {})
# medium 우선 # medium 우선
for quality in ('medium', 'large', 'small', 'tiny'): for quality in ('medium', 'large', 'small', 'tiny'):
@@ -157,6 +204,7 @@ def _prepare_clip(input_path: Path, output_path: Path, duration: float = 6.0) ->
), ),
'-r', '30', '-r', '30',
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast', '-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
'-pix_fmt', 'yuv420p',
'-an', # 스톡 클립 오디오 제거 '-an', # 스톡 클립 오디오 제거
str(output_path), str(output_path),
] ]

View File

@@ -128,6 +128,7 @@ def _concat_with_xfade(clips: list[Path], output: Path, crossfade: float, ffmpeg
'-filter_complex', filter_complex, '-filter_complex', filter_complex,
'-map', prev_label, '-map', prev_label,
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast', '-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
'-pix_fmt', 'yuv420p',
'-an', '-r', '30', '-an', '-r', '30',
str(output), str(output),
] ]
@@ -151,6 +152,7 @@ def _concat_simple(clips: list[Path], output: Path, ffmpeg: str) -> bool:
'-f', 'concat', '-safe', '0', '-f', 'concat', '-safe', '0',
'-i', str(list_file), '-i', str(list_file),
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast', '-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
'-pix_fmt', 'yuv420p',
'-an', '-r', '30', '-an', '-r', '30',
str(output), str(output),
] ]
@@ -240,8 +242,11 @@ def _assemble_final(
f'apad=pad_dur=0.2' # 루프 최적화: 0.2s 무음 f'apad=pad_dur=0.2' # 루프 최적화: 0.2s 무음
), ),
'-c:v', codec, '-crf', str(crf), '-preset', 'medium', '-c:v', codec, '-crf', str(crf), '-preset', 'medium',
'-pix_fmt', 'yuv420p',
'-profile:v', 'high', '-level', '4.0',
'-c:a', audio_codec, '-b:a', audio_bitrate, '-c:a', audio_codec, '-b:a', audio_bitrate,
'-r', str(vid_cfg.get('fps', 30)), '-r', str(vid_cfg.get('fps', 30)),
'-movflags', '+faststart',
'-shortest', '-shortest',
str(output), str(output),
] ]
@@ -266,7 +271,9 @@ def _rerender_smaller(src: Path, dst: Path, ffmpeg: str) -> bool:
cmd = [ cmd = [
ffmpeg, '-y', '-i', str(src), ffmpeg, '-y', '-i', str(src),
'-c:v', 'libx264', '-crf', '23', '-preset', 'medium', '-c:v', 'libx264', '-crf', '23', '-preset', 'medium',
'-pix_fmt', 'yuv420p', '-profile:v', 'high', '-level', '4.0',
'-c:a', 'aac', '-b:a', '128k', '-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
str(dst), str(dst),
] ]
try: try: