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:
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user