Add intro text improvements and BGM download management

- Intro text: Add 0.7s freeze frame effect before video plays
- Intro text: Auto-scale font size to prevent overflow
- Intro text: Split long text into 2 lines automatically
- BGM Page: Add free BGM download section with 6 categories
- BGM Page: Support individual and bulk download from Freesound.org

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kihong.kim
2026-01-04 19:01:58 +09:00
parent 3f9fc90e61
commit 49ca7d8913
2 changed files with 212 additions and 63 deletions

View File

@@ -74,8 +74,11 @@ async def process_video(
# Build video filter chain # Build video filter chain
video_filters = [] video_filters = []
# Note: We no longer use tpad to add frozen frames, as it extends the video duration. # 1. Add freeze frame at the beginning if intro text is provided
# Instead, intro text is simply overlaid on the existing video content. # tpad adds frozen frames at start using clone mode (copies first frame)
if intro_text and intro_duration > 0:
# Clone the first frame for intro_duration seconds
video_filters.append(f"tpad=start_duration={intro_duration}:start_mode=clone")
# 2. Add subtitle overlay if provided # 2. Add subtitle overlay if provided
if subtitle_path and os.path.exists(subtitle_path): if subtitle_path and os.path.exists(subtitle_path):
@@ -89,6 +92,7 @@ async def process_video(
"/System/Library/Fonts/Supplemental/AppleGothic.ttf", # macOS Korean "/System/Library/Fonts/Supplemental/AppleGothic.ttf", # macOS Korean
"/System/Library/Fonts/AppleSDGothicNeo.ttc", # macOS Korean "/System/Library/Fonts/AppleSDGothicNeo.ttc", # macOS Korean
"/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", # Linux Korean "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf", # Linux Korean
"/usr/share/fonts/truetype/korean/Pretendard-Bold.otf", # Docker Korean
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", # Linux CJK "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", # Linux CJK
] ]
@@ -98,73 +102,76 @@ async def process_video(
font_file = font.replace(":", "\\:") font_file = font.replace(":", "\\:")
break break
# Adjust font size and split text if too long # Calculate font size based on text length to prevent overflow
# Shorts video is 1080 width, so ~10-12 chars fit comfortably at 100px # Shorts video is typically 720px width
text_len = len(intro_text) # Korean characters are nearly square (width ≈ height), so char_width_ratio ≈ 1.0
adjusted_font_size = intro_font_size video_width = 720 # Default Shorts width
box_padding = 40 # boxborderw=20 on each side
max_width_ratio = 0.75 # Leave 25% margin for safety
char_width_ratio = 1.0 # Korean characters are nearly square
available_width = (video_width * max_width_ratio) - box_padding
# Split into 2 lines if text is long (more than 10 chars) # Split text into 2 lines if too long (more than 10 chars or font would be too small)
lines = [] text_len = len(intro_text)
if text_len > 10: single_line_font = int(available_width / (text_len * char_width_ratio))
# Find best split point near middle
mid = text_len // 2 # Use 2 lines if single line font would be less than 50px
if single_line_font < 50 and text_len > 6:
# Find best split point (prefer space near middle)
mid = len(intro_text) // 2
split_pos = None
# Search for space within 5 chars of middle
for offset in range(6):
if mid + offset < len(intro_text) and intro_text[mid + offset] == ' ':
split_pos = mid + offset
break
if mid - offset >= 0 and intro_text[mid - offset] == ' ':
split_pos = mid - offset
break
# If no space found, split at middle
if split_pos is None:
split_pos = mid split_pos = mid
for i in range(mid, max(0, mid - 5), -1):
if intro_text[i] in ' ,、,':
split_pos = i + 1
break
for i in range(mid, min(text_len, mid + 5)):
if intro_text[i] in ' ,、,':
split_pos = i + 1
break
line1 = intro_text[:split_pos].strip() line1 = intro_text[:split_pos].strip()
line2 = intro_text[split_pos:].strip() line2 = intro_text[split_pos:].strip()
if line2: display_text = f"{line1}\\n{line2}"
lines = [line1, line2]
# Calculate font size based on longer line
max_line_len = max(len(line1), len(line2))
calculated_max_font = int(available_width / (max_line_len * char_width_ratio))
print(f"[Intro] Split into 2 lines: '{line1}' / '{line2}' (max {max_line_len} chars)")
else: else:
lines = [intro_text] display_text = intro_text
else: calculated_max_font = single_line_font
lines = [intro_text] print(f"[Intro] Single line: '{intro_text}' ({text_len} chars)")
# Adjust font size based on longest line length adjusted_font_size = min(intro_font_size, calculated_max_font)
max_line_len = max(len(line) for line in lines) adjusted_font_size = max(36, adjusted_font_size) # Minimum 36px font size
if max_line_len > 12: print(f"[Intro] Requested font: {intro_font_size}px, Adjusted: {adjusted_font_size}px")
adjusted_font_size = int(intro_font_size * 10 / max_line_len)
adjusted_font_size = max(50, min(adjusted_font_size, intro_font_size)) # Clamp between 50-100
# Add fade effect timing # Fade out effect timing (fade starts 0.2s before end)
fade_out_start = max(0.1, intro_duration - 0.3) fade_out_start = max(0.1, intro_duration - 0.2)
alpha_expr = f"if(gt(t,{fade_out_start}),(({intro_duration}-t)/0.3),1)" alpha_expr = f"if(gt(t,{fade_out_start}),(({intro_duration}-t)/0.2),1)"
# Create drawtext filter(s) for each line escaped_text = display_text.replace("'", "\\'").replace(":", "\\:")
line_height = adjusted_font_size + 20
total_height = line_height * len(lines)
for i, line in enumerate(lines):
escaped_text = line.replace("'", "\\'").replace(":", "\\:").replace("\\", "\\\\")
# Calculate y position for this line (centered overall)
if len(lines) == 1:
y_expr = "(h-text_h)/2"
else:
# Center the block of lines, then position each line
y_offset = int((i - (len(lines) - 1) / 2) * line_height)
y_expr = f"(h-text_h)/2+{y_offset}"
# Draw text centered on screen during freeze frame
drawtext_parts = [ drawtext_parts = [
f"text='{escaped_text}'", f"text='{escaped_text}'",
f"fontsize={adjusted_font_size}", f"fontsize={adjusted_font_size}",
"fontcolor=white", "fontcolor=white",
"x=(w-text_w)/2", # Center horizontally "x=(w-text_w)/2", # Center horizontally
f"y={y_expr}", "y=(h-text_h)/2", # Center vertically
f"enable='lt(t,{intro_duration})'", f"enable='lt(t,{intro_duration})'",
"borderw=3", "borderw=4",
"bordercolor=black", "bordercolor=black",
"box=1", "box=1",
"boxcolor=black@0.6", "boxcolor=black@0.7",
"boxborderw=15", "boxborderw=20",
f"alpha='{alpha_expr}'", f"alpha='{alpha_expr}'",
"line_spacing=10", # Add spacing between lines
] ]
if font_file: if font_file:

View File

@@ -1,12 +1,24 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Upload, Trash2, Play, Pause, Music } from 'lucide-react'; import { Upload, Trash2, Play, Pause, Music, Download, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { bgmApi } from '../api/client'; import { bgmApi } from '../api/client';
// 무료 BGM 카테고리
const BGM_CATEGORIES = [
{ id: 'upbeat', name: '신나는', keywords: ['upbeat', 'energetic', 'happy'] },
{ id: 'chill', name: '차분한', keywords: ['chill', 'calm', 'relaxing'] },
{ id: 'cinematic', name: '시네마틱', keywords: ['cinematic', 'epic', 'dramatic'] },
{ id: 'acoustic', name: '어쿠스틱', keywords: ['acoustic', 'guitar', 'folk'] },
{ id: 'electronic', name: '일렉트로닉', keywords: ['electronic', 'edm', 'dance'] },
{ id: 'lofi', name: '로파이', keywords: ['lofi', 'hip hop', 'beats'] },
];
export default function BGMPage() { export default function BGMPage() {
const [bgmList, setBgmList] = useState([]); const [bgmList, setBgmList] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [playingId, setPlayingId] = useState(null); const [playingId, setPlayingId] = useState(null);
const [downloading, setDownloading] = useState(null); // 현재 다운로드 중인 카테고리 ID
const [downloadStatus, setDownloadStatus] = useState({}); // 각 카테고리별 다운로드 상태
const audioRef = useRef(null); const audioRef = useRef(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
@@ -85,6 +97,67 @@ export default function BGMPage() {
return `${mins}:${String(secs).padStart(2, '0')}`; return `${mins}:${String(secs).padStart(2, '0')}`;
}; };
// 무료 BGM 다운로드
const handleDownloadBgm = async (category) => {
if (downloading) return; // 이미 다운로드 중이면 무시
setDownloading(category.id);
setDownloadStatus(prev => ({ ...prev, [category.id]: 'downloading' }));
try {
const res = await bgmApi.autoDownload(category.keywords, 120);
if (res.data.success) {
setDownloadStatus(prev => ({ ...prev, [category.id]: 'success' }));
// BGM 리스트 새로고침
await fetchBgmList();
} else {
setDownloadStatus(prev => ({ ...prev, [category.id]: 'error' }));
}
} catch (err) {
console.error('BGM download failed:', err);
setDownloadStatus(prev => ({ ...prev, [category.id]: 'error' }));
} finally {
setDownloading(null);
// 3초 후 상태 초기화
setTimeout(() => {
setDownloadStatus(prev => ({ ...prev, [category.id]: null }));
}, 3000);
}
};
// 전체 카테고리 다운로드
const handleDownloadAll = async () => {
if (downloading) return;
for (const category of BGM_CATEGORIES) {
setDownloading(category.id);
setDownloadStatus(prev => ({ ...prev, [category.id]: 'downloading' }));
try {
const res = await bgmApi.autoDownload(category.keywords, 120);
if (res.data.success) {
setDownloadStatus(prev => ({ ...prev, [category.id]: 'success' }));
} else {
setDownloadStatus(prev => ({ ...prev, [category.id]: 'error' }));
}
} catch (err) {
console.error(`BGM download failed for ${category.name}:`, err);
setDownloadStatus(prev => ({ ...prev, [category.id]: 'error' }));
}
// 잠시 대기 (API 제한 방지)
await new Promise(resolve => setTimeout(resolve, 1000));
}
setDownloading(null);
await fetchBgmList();
// 5초 후 상태 초기화
setTimeout(() => {
setDownloadStatus({});
}, 5000);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -109,6 +182,75 @@ export default function BGMPage() {
className="hidden" className="hidden"
/> />
{/* 무료 BGM 다운로드 섹션 */}
<div className="card">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium flex items-center gap-2">
<Download size={18} />
무료 BGM 다운로드
</h3>
<p className="text-sm text-gray-500 mt-1">
Freesound.org에서 CC0 라이선스 BGM을 다운로드합니다 (상업용 가능)
</p>
</div>
<button
onClick={handleDownloadAll}
disabled={!!downloading}
className="btn-secondary text-sm flex items-center gap-2"
>
{downloading ? (
<>
<Loader2 size={14} className="animate-spin" />
다운로드 ...
</>
) : (
<>
<Download size={14} />
전체 다운로드
</>
)}
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{BGM_CATEGORIES.map((category) => {
const status = downloadStatus[category.id];
const isDownloading = downloading === category.id;
return (
<button
key={category.id}
onClick={() => handleDownloadBgm(category)}
disabled={!!downloading}
className={`p-3 rounded-lg border text-center transition-all ${
status === 'success'
? 'bg-green-900/30 border-green-600 text-green-400'
: status === 'error'
? 'bg-red-900/30 border-red-600 text-red-400'
: isDownloading
? 'bg-blue-900/30 border-blue-600 text-blue-400'
: 'bg-gray-800 border-gray-700 hover:border-gray-600 text-gray-300'
} ${downloading && !isDownloading ? 'opacity-50' : ''}`}
>
<div className="flex items-center justify-center gap-2">
{isDownloading ? (
<Loader2 size={16} className="animate-spin" />
) : status === 'success' ? (
<CheckCircle size={16} />
) : status === 'error' ? (
<XCircle size={16} />
) : (
<Music size={16} />
)}
<span className="font-medium">{category.name}</span>
</div>
</button>
);
})}
</div>
</div>
{isLoading ? ( {isLoading ? (
<div className="card text-center py-12 text-gray-500"> <div className="card text-center py-12 text-gray-500">
로딩 ... 로딩 ...