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
|
# 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,79 +102,82 @@ 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
|
||||||
split_pos = mid
|
if single_line_font < 50 and text_len > 6:
|
||||||
for i in range(mid, max(0, mid - 5), -1):
|
# Find best split point (prefer space near middle)
|
||||||
if intro_text[i] in ' ,、,':
|
mid = len(intro_text) // 2
|
||||||
split_pos = i + 1
|
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
|
break
|
||||||
for i in range(mid, min(text_len, mid + 5)):
|
if mid - offset >= 0 and intro_text[mid - offset] == ' ':
|
||||||
if intro_text[i] in ' ,、,':
|
split_pos = mid - offset
|
||||||
split_pos = i + 1
|
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# If no space found, split at middle
|
||||||
|
if split_pos is None:
|
||||||
|
split_pos = mid
|
||||||
|
|
||||||
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]
|
|
||||||
else:
|
# Calculate font size based on longer line
|
||||||
lines = [intro_text]
|
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
|
||||||
|
calculated_max_font = single_line_font
|
||||||
|
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):
|
# Draw text centered on screen during freeze frame
|
||||||
escaped_text = line.replace("'", "\\'").replace(":", "\\:").replace("\\", "\\\\")
|
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 font_file:
|
||||||
if len(lines) == 1:
|
drawtext_parts.insert(1, f"fontfile='{font_file}'")
|
||||||
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}"
|
|
||||||
|
|
||||||
drawtext_parts = [
|
video_filters.append(f"drawtext={':'.join(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)}")
|
|
||||||
|
|
||||||
# Combine video filters
|
# Combine video filters
|
||||||
video_filter_str = ",".join(video_filters) if video_filters else None
|
video_filter_str = ",".join(video_filters) if video_filters else None
|
||||||
|
|||||||
@@ -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">
|
||||||
로딩 중...
|
로딩 중...
|
||||||
|
|||||||
Reference in New Issue
Block a user