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:
@@ -74,8 +74,11 @@ async def process_video(
|
||||
# Build video filter chain
|
||||
video_filters = []
|
||||
|
||||
# Note: We no longer use tpad to add frozen frames, as it extends the video duration.
|
||||
# Instead, intro text is simply overlaid on the existing video content.
|
||||
# 1. Add freeze frame at the beginning if intro text is provided
|
||||
# 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
|
||||
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/AppleSDGothicNeo.ttc", # macOS 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
|
||||
]
|
||||
|
||||
@@ -98,79 +102,82 @@ async def process_video(
|
||||
font_file = font.replace(":", "\\:")
|
||||
break
|
||||
|
||||
# Adjust font size and split text if too long
|
||||
# Shorts video is 1080 width, so ~10-12 chars fit comfortably at 100px
|
||||
text_len = len(intro_text)
|
||||
adjusted_font_size = intro_font_size
|
||||
# Calculate font size based on text length to prevent overflow
|
||||
# Shorts video is typically 720px width
|
||||
# Korean characters are nearly square (width ≈ height), so char_width_ratio ≈ 1.0
|
||||
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)
|
||||
lines = []
|
||||
if text_len > 10:
|
||||
# Find best split point near middle
|
||||
mid = text_len // 2
|
||||
split_pos = mid
|
||||
for i in range(mid, max(0, mid - 5), -1):
|
||||
if intro_text[i] in ' ,、,':
|
||||
split_pos = i + 1
|
||||
# Split text into 2 lines if too long (more than 10 chars or font would be too small)
|
||||
text_len = len(intro_text)
|
||||
single_line_font = int(available_width / (text_len * char_width_ratio))
|
||||
|
||||
# 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
|
||||
for i in range(mid, min(text_len, mid + 5)):
|
||||
if intro_text[i] in ' ,、,':
|
||||
split_pos = i + 1
|
||||
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
|
||||
|
||||
line1 = intro_text[:split_pos].strip()
|
||||
line2 = intro_text[split_pos:].strip()
|
||||
if line2:
|
||||
lines = [line1, line2]
|
||||
else:
|
||||
lines = [intro_text]
|
||||
display_text = f"{line1}\\n{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:
|
||||
lines = [intro_text]
|
||||
display_text = intro_text
|
||||
calculated_max_font = single_line_font
|
||||
print(f"[Intro] Single line: '{intro_text}' ({text_len} chars)")
|
||||
|
||||
# Adjust font size based on longest line length
|
||||
max_line_len = max(len(line) for line in lines)
|
||||
if max_line_len > 12:
|
||||
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
|
||||
adjusted_font_size = min(intro_font_size, calculated_max_font)
|
||||
adjusted_font_size = max(36, adjusted_font_size) # Minimum 36px font size
|
||||
print(f"[Intro] Requested font: {intro_font_size}px, Adjusted: {adjusted_font_size}px")
|
||||
|
||||
# Add fade effect timing
|
||||
fade_out_start = max(0.1, intro_duration - 0.3)
|
||||
alpha_expr = f"if(gt(t,{fade_out_start}),(({intro_duration}-t)/0.3),1)"
|
||||
# Fade out effect timing (fade starts 0.2s before end)
|
||||
fade_out_start = max(0.1, intro_duration - 0.2)
|
||||
alpha_expr = f"if(gt(t,{fade_out_start}),(({intro_duration}-t)/0.2),1)"
|
||||
|
||||
# Create drawtext filter(s) for each line
|
||||
line_height = adjusted_font_size + 20
|
||||
total_height = line_height * len(lines)
|
||||
escaped_text = display_text.replace("'", "\\'").replace(":", "\\:")
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
escaped_text = line.replace("'", "\\'").replace(":", "\\:").replace("\\", "\\\\")
|
||||
# Draw text centered on screen during freeze frame
|
||||
drawtext_parts = [
|
||||
f"text='{escaped_text}'",
|
||||
f"fontsize={adjusted_font_size}",
|
||||
"fontcolor=white",
|
||||
"x=(w-text_w)/2", # Center horizontally
|
||||
"y=(h-text_h)/2", # Center vertically
|
||||
f"enable='lt(t,{intro_duration})'",
|
||||
"borderw=4",
|
||||
"bordercolor=black",
|
||||
"box=1",
|
||||
"boxcolor=black@0.7",
|
||||
"boxborderw=20",
|
||||
f"alpha='{alpha_expr}'",
|
||||
"line_spacing=10", # Add spacing between lines
|
||||
]
|
||||
|
||||
# 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}"
|
||||
if font_file:
|
||||
drawtext_parts.insert(1, f"fontfile='{font_file}'")
|
||||
|
||||
drawtext_parts = [
|
||||
f"text='{escaped_text}'",
|
||||
f"fontsize={adjusted_font_size}",
|
||||
"fontcolor=white",
|
||||
"x=(w-text_w)/2", # Center horizontally
|
||||
f"y={y_expr}",
|
||||
f"enable='lt(t,{intro_duration})'",
|
||||
"borderw=3",
|
||||
"bordercolor=black",
|
||||
"box=1",
|
||||
"boxcolor=black@0.6",
|
||||
"boxborderw=15",
|
||||
f"alpha='{alpha_expr}'",
|
||||
]
|
||||
|
||||
if font_file:
|
||||
drawtext_parts.insert(1, f"fontfile='{font_file}'")
|
||||
|
||||
video_filters.append(f"drawtext={':'.join(drawtext_parts)}")
|
||||
video_filters.append(f"drawtext={':'.join(drawtext_parts)}")
|
||||
|
||||
# Combine video filters
|
||||
video_filter_str = ",".join(video_filters) if video_filters else None
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
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';
|
||||
|
||||
// 무료 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() {
|
||||
const [bgmList, setBgmList] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [playingId, setPlayingId] = useState(null);
|
||||
const [downloading, setDownloading] = useState(null); // 현재 다운로드 중인 카테고리 ID
|
||||
const [downloadStatus, setDownloadStatus] = useState({}); // 각 카테고리별 다운로드 상태
|
||||
const audioRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
@@ -85,6 +97,67 @@ export default function BGMPage() {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -109,6 +182,75 @@ export default function BGMPage() {
|
||||
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 ? (
|
||||
<div className="card text-center py-12 text-gray-500">
|
||||
로딩 중...
|
||||
|
||||
Reference in New Issue
Block a user