44from pathlib import Path
55from fastapi import APIRouter , HTTPException
66from pydantic import BaseModel
7- from typing import Optional , Union
7+ from typing import Optional
88
99router = APIRouter ()
1010
1414OUTPUT_VIDEO_DIR .mkdir (parents = True , exist_ok = True )
1515OUTPUT_ASS_DIR .mkdir (parents = True , exist_ok = True )
1616
17+ # FE 미리보기 픽셀값을 ASS 좌표(PlayResY=1920)로 변환할 때 곱하는 배수
18+ FONT_SIZE_SCALE = 3
1719
18- # ── 매핑 테이블 ────────────────────────────
19- # 한글 폰트명 → 시스템 설치된 폰트명 (개발은 일단 맑은 고딕으로 통일, 추후 교체 가능)
2020FONT_MAP = {
2121 "통통체" : "NanumGothic" ,
2222 "각진체" : "NanumGothic" ,
2323 "얇은체" : "NanumGothic" ,
2424 "고딕" : "NanumGothic" ,
2525}
2626
27- # 한글 위치 → ASS Alignment (numpad 1~9)
2827POSITION_MAP = {
29- "상단" : 8 , # top center
30- "중앙" : 5 , # middle center
31- "하단" : 2 , # bottom center
28+ "상단" : 8 , "중앙" : 5 , "하단" : 2 ,
3229 "top" : 8 , "middle" : 5 , "bottom" : 2 , "center" : 5 ,
3330}
3431
3532
36- # ── Pydantic 스키마 ────────────────────────
3733class Segment (BaseModel ):
3834 start : float
3935 end : float
@@ -47,12 +43,10 @@ class Segment(BaseModel):
4743
4844class ProcessRequest (BaseModel ):
4945 video_id : str
50- segments : list [Segment ]
46+ subtitles : list [Segment ]
5147
5248
53- # ── 유틸 함수 ──────────────────────────────
5449def hex_to_ass_color (hex_color : str ) -> str :
55- """#RRGGBB → &H00BBGGRR (ASS는 BGR 순서)"""
5650 h = hex_color .lstrip ("#" ).upper ()
5751 if len (h ) == 3 :
5852 h = "" .join (c * 2 for c in h )
@@ -63,7 +57,6 @@ def hex_to_ass_color(hex_color: str) -> str:
6357
6458
6559def to_ass_time (sec : float ) -> str :
66- """0.0 → 0:00:00.00 (센티초)"""
6760 h = int (sec // 3600 )
6861 m = int ((sec % 3600 ) // 60 )
6962 s = int (sec % 60 )
@@ -73,24 +66,21 @@ def to_ass_time(sec: float) -> str:
7366 return f"{ h } :{ m :02d} :{ s :02d} .{ cs :02d} "
7467
7568
76- def build_ass (segments : list [Segment ]) -> str :
77- """Segment 리스트 → ASS 파일 텍스트"""
69+ def build_ass (segments ):
7870 header = (
7971 "[Script Info]\n "
8072 "ScriptType: v4.00+\n "
8173 "PlayResX: 1080\n "
8274 "PlayResY: 1920\n "
8375 "WrapStyle: 0\n "
84- "ScaledBorderAndShadow: yes\n "
85- "\n "
76+ "ScaledBorderAndShadow: yes\n \n "
8677 "[V4+ Styles]\n "
8778 "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
8879 "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
8980 "ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
9081 "Alignment, MarginL, MarginR, MarginV, Encoding\n "
91- "Style: Default,Malgun Gothic,50,&H00FFFFFF,&H000000FF,&H00000000,"
92- "&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,40,40,80,1\n "
93- "\n "
82+ "Style: Default,NanumGothic,60,&H00FFFFFF,&H000000FF,&H00000000,"
83+ "&H80000000,0,0,0,0,100,100,0,0,1,3,1,2,40,40,80,1\n \n "
9484 "[Events]\n "
9585 "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, "
9686 "Effect, Text\n "
@@ -103,17 +93,13 @@ def build_ass(segments: list[Segment]) -> str:
10393 color_ass = hex_to_ass_color (seg .color or "#FFFFFF" )
10494 font_name = FONT_MAP .get (seg .font or "고딕" , "NanumGothic" )
10595 alignment = POSITION_MAP .get (seg .position or "하단" , 2 )
106- font_size = seg .fontSize or 50
10796
108- # 인라인 오버라이드 태그로 segment별 스타일 적용
97+ # FE 픽셀값 → ASS 좌표로 스케일업
98+ font_size = (seg .fontSize or 20 ) * FONT_SIZE_SCALE
99+
109100 override = (
110- f"{{\\ an{ alignment } "
111- f"\\ fn{ font_name } "
112- f"\\ fs{ font_size } "
113- f"\\ c{ color_ass } "
114- f"}}"
101+ f"{{\\ an{ alignment } \\ fn{ font_name } \\ fs{ font_size } \\ c{ color_ass } }}"
115102 )
116- # 줄바꿈은 \\N (ASS 표준)
117103 text = seg .text .replace ("\n " , "\\ N" ).replace ("\r " , "" )
118104 lines .append (
119105 f"Dialogue: 0,{ start_t } ,{ end_t } ,Default,,0,0,0,,{ override } { text } "
@@ -122,12 +108,8 @@ def build_ass(segments: list[Segment]) -> str:
122108 return "\n " .join (lines ) + "\n "
123109
124110
125- # ── 메인 엔드포인트 ────────────────────────
126111@router .post ("/process" )
127112async def process_video (req : ProcessRequest ):
128- if "/" in req .video_id or "\\ " in req .video_id or ".." in req .video_id :
129- raise HTTPException (400 , detail = "Invalid video_id" )
130-
131113 video_path = UPLOAD_DIR / f"{ req .video_id } .mp4"
132114 if not video_path .exists ():
133115 raise HTTPException (404 , detail = "VIDEO_NOT_FOUND" )
@@ -136,12 +118,9 @@ async def process_video(req: ProcessRequest):
136118 output_ass = OUTPUT_ASS_DIR / f"{ req .video_id } .ass"
137119
138120 try :
139- # ① ASS 파일 생성
140- ass_text = build_ass (req .segments )
121+ ass_text = build_ass (req .subtitles )
141122 output_ass .write_text (ass_text , encoding = "utf-8" )
142123
143- # ② ffmpeg로 ASS + MP4 합성
144- # subtitles 필터는 경로에 콜론/슬래시 escape 까다로움 → cwd 지정으로 우회
145124 ass_filename = output_ass .name
146125 cmd = [
147126 "ffmpeg" , "-y" ,
@@ -164,6 +143,5 @@ async def process_video(req: ProcessRequest):
164143 return {"success" : True , "video_id" : req .video_id }
165144
166145 except Exception as e :
167- # 실패 마커
168146 (OUTPUT_VIDEO_DIR / f"{ req .video_id } .failed" ).touch ()
169147 raise HTTPException (500 , detail = f"PROCESS_FAILED: { str (e )} " )
0 commit comments