From 49ca7d8913fce7c7a114fd11f3e77e573906a870 Mon Sep 17 00:00:00 2001 From: "kihong.kim" Date: Sun, 4 Jan 2026 19:01:58 +0900 Subject: [PATCH] Add intro text improvements and BGM download management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/services/video_processor.py | 131 +++++++++++---------- frontend/src/pages/BGMPage.jsx | 144 +++++++++++++++++++++++- 2 files changed, 212 insertions(+), 63 deletions(-) diff --git a/backend/app/services/video_processor.py b/backend/app/services/video_processor.py index 784e98f..9da6090 100644 --- a/backend/app/services/video_processor.py +++ b/backend/app/services/video_processor.py @@ -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 diff --git a/frontend/src/pages/BGMPage.jsx b/frontend/src/pages/BGMPage.jsx index 0e67e4c..2cc1936 100644 --- a/frontend/src/pages/BGMPage.jsx +++ b/frontend/src/pages/BGMPage.jsx @@ -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 (
@@ -109,6 +182,75 @@ export default function BGMPage() { className="hidden" /> + {/* ๋ฌด๋ฃŒ BGM ๋‹ค์šด๋กœ๋“œ ์„น์…˜ */} +
+
+
+

+ + ๋ฌด๋ฃŒ BGM ๋‹ค์šด๋กœ๋“œ +

+

+ Freesound.org์—์„œ CC0 ๋ผ์ด์„ ์Šค BGM์„ ๋‹ค์šด๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค (์ƒ์—…์šฉ ๊ฐ€๋Šฅ) +

+
+ +
+ +
+ {BGM_CATEGORIES.map((category) => { + const status = downloadStatus[category.id]; + const isDownloading = downloading === category.id; + + return ( + + ); + })} +
+
+ {isLoading ? (
๋กœ๋”ฉ ์ค‘...