feat: Drama Studio 프로젝트 초기 구조 설정
- 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>
This commit is contained in:
174
audio-studio-api/app/services/script_parser.py
Normal file
174
audio-studio-api/app/services/script_parser.py
Normal file
@ -0,0 +1,174 @@
|
||||
# 드라마 스크립트 파서
|
||||
# 마크다운 형식의 대본을 구조화된 데이터로 변환
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user