- FastAPI 백엔드 (audio-studio-api) - Next.js 프론트엔드 (audio-studio-ui) - Qwen3-TTS 엔진 (audio-studio-tts) - MusicGen 서비스 (audio-studio-musicgen) - Docker Compose 개발/운영 환경 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
175 lines
5.7 KiB
Python
175 lines
5.7 KiB
Python
# 드라마 스크립트 파서
|
|
# 마크다운 형식의 대본을 구조화된 데이터로 변환
|
|
|
|
import re
|
|
from typing import Optional
|
|
from app.models.drama import (
|
|
ParsedScript, ScriptElement, Character, ElementType
|
|
)
|
|
|
|
|
|
class ScriptParser:
|
|
"""
|
|
드라마 스크립트 파서
|
|
|
|
지원 형식:
|
|
- # 제목
|
|
- [장소: 설명] 또는 [지문]
|
|
- [효과음: 설명]
|
|
- [음악: 설명] 또는 [음악 시작/중지/변경: 설명]
|
|
- [쉼: 2초]
|
|
- 캐릭터명(설명, 감정): 대사
|
|
- 캐릭터명: 대사
|
|
"""
|
|
|
|
# 정규식 패턴
|
|
TITLE_PATTERN = re.compile(r'^#\s+(.+)$')
|
|
DIRECTION_PATTERN = re.compile(r'^\[(?:장소|지문|장면):\s*(.+)\]$')
|
|
SFX_PATTERN = re.compile(r'^\[효과음:\s*(.+)\]$')
|
|
MUSIC_PATTERN = re.compile(r'^\[음악(?:\s+(시작|중지|변경|페이드인|페이드아웃))?:\s*(.+)\]$')
|
|
PAUSE_PATTERN = re.compile(r'^\[쉼:\s*(\d+(?:\.\d+)?)\s*초?\]$')
|
|
DIALOGUE_PATTERN = re.compile(r'^([^(\[:]+?)(?:\(([^)]*)\))?:\s*(.+)$')
|
|
|
|
# 음악 액션 매핑
|
|
MUSIC_ACTIONS = {
|
|
None: "play",
|
|
"시작": "play",
|
|
"중지": "stop",
|
|
"변경": "change",
|
|
"페이드인": "fade_in",
|
|
"페이드아웃": "fade_out",
|
|
}
|
|
|
|
def parse(self, script: str) -> ParsedScript:
|
|
"""스크립트 파싱"""
|
|
lines = script.strip().split('\n')
|
|
|
|
title: Optional[str] = None
|
|
characters: dict[str, Character] = {}
|
|
elements: list[ScriptElement] = []
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# 제목
|
|
if match := self.TITLE_PATTERN.match(line):
|
|
title = match.group(1)
|
|
continue
|
|
|
|
# 지문/장면
|
|
if match := self.DIRECTION_PATTERN.match(line):
|
|
elements.append(ScriptElement(
|
|
type=ElementType.DIRECTION,
|
|
text=match.group(1)
|
|
))
|
|
continue
|
|
|
|
# 효과음
|
|
if match := self.SFX_PATTERN.match(line):
|
|
elements.append(ScriptElement(
|
|
type=ElementType.SFX,
|
|
description=match.group(1),
|
|
volume=1.0
|
|
))
|
|
continue
|
|
|
|
# 음악
|
|
if match := self.MUSIC_PATTERN.match(line):
|
|
action_kr = match.group(1)
|
|
action = self.MUSIC_ACTIONS.get(action_kr, "play")
|
|
elements.append(ScriptElement(
|
|
type=ElementType.MUSIC,
|
|
description=match.group(2),
|
|
action=action,
|
|
volume=0.3,
|
|
fade_duration=2.0
|
|
))
|
|
continue
|
|
|
|
# 쉼
|
|
if match := self.PAUSE_PATTERN.match(line):
|
|
elements.append(ScriptElement(
|
|
type=ElementType.PAUSE,
|
|
duration=float(match.group(1))
|
|
))
|
|
continue
|
|
|
|
# 대사
|
|
if match := self.DIALOGUE_PATTERN.match(line):
|
|
char_name = match.group(1).strip()
|
|
char_info = match.group(2) # 괄호 안 내용 (설명, 감정)
|
|
dialogue_text = match.group(3).strip()
|
|
|
|
# 캐릭터 정보 파싱
|
|
emotion = None
|
|
description = None
|
|
if char_info:
|
|
parts = [p.strip() for p in char_info.split(',')]
|
|
if len(parts) >= 2:
|
|
description = parts[0]
|
|
emotion = parts[1]
|
|
else:
|
|
# 단일 값은 감정으로 처리
|
|
emotion = parts[0]
|
|
|
|
# 캐릭터 등록
|
|
if char_name not in characters:
|
|
characters[char_name] = Character(
|
|
name=char_name,
|
|
description=description
|
|
)
|
|
elif description and not characters[char_name].description:
|
|
characters[char_name].description = description
|
|
|
|
elements.append(ScriptElement(
|
|
type=ElementType.DIALOGUE,
|
|
character=char_name,
|
|
text=dialogue_text,
|
|
emotion=emotion
|
|
))
|
|
continue
|
|
|
|
# 매칭 안 되는 줄은 지문으로 처리 (대괄호 없는 일반 텍스트)
|
|
if not line.startswith('[') and not line.startswith('#'):
|
|
# 콜론이 없으면 지문으로 처리
|
|
if ':' not in line:
|
|
elements.append(ScriptElement(
|
|
type=ElementType.DIRECTION,
|
|
text=line
|
|
))
|
|
|
|
return ParsedScript(
|
|
title=title,
|
|
characters=list(characters.values()),
|
|
elements=elements
|
|
)
|
|
|
|
def validate_script(self, script: str) -> tuple[bool, list[str]]:
|
|
"""
|
|
스크립트 유효성 검사
|
|
Returns: (is_valid, error_messages)
|
|
"""
|
|
errors = []
|
|
|
|
if not script or not script.strip():
|
|
errors.append("스크립트가 비어있습니다")
|
|
return False, errors
|
|
|
|
parsed = self.parse(script)
|
|
|
|
if not parsed.elements:
|
|
errors.append("파싱된 요소가 없습니다")
|
|
|
|
# 대사가 있는지 확인
|
|
dialogue_count = sum(1 for e in parsed.elements if e.type == ElementType.DIALOGUE)
|
|
if dialogue_count == 0:
|
|
errors.append("대사가 없습니다")
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
# 싱글톤 인스턴스
|
|
script_parser = ScriptParser()
|