Files
drama-studio/audio-studio-api/app/services/script_parser.py
jungwoo choi cc547372c0 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>
2026-01-26 11:39:38 +09:00

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()