feat: 타임라인 에디터 및 비디오 스튜디오 컴포넌트 추가
- TimelineEditor, VideoStudio 컴포넌트 신규 추가 - 백엔드 transcriber, video_processor 서비스 개선 - 프론트엔드 HomePage 리팩토링 및 스타일 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,18 +32,20 @@ class DownloadResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SubtitleStyle(BaseModel):
|
class SubtitleStyle(BaseModel):
|
||||||
font_size: int = 28
|
font_size: int = 70
|
||||||
font_color: str = "white"
|
font_color: str = "FFFFFF" # hex color code (white)
|
||||||
outline_color: str = "black"
|
outline_color: str = "000000" # hex color code (black)
|
||||||
outline_width: int = 2
|
outline_width: int = 4 # 아웃라인 두께 (가독성을 위해 4로 증가)
|
||||||
position: str = "bottom" # top, center, bottom
|
position: str = "center" # top, center, bottom
|
||||||
|
margin_v: int = 50 # 수직 위치 (0=가장자리, 100=화면 중심쪽) - 화면 높이의 %
|
||||||
font_name: str = "Pretendard"
|
font_name: str = "Pretendard"
|
||||||
# Enhanced styling options
|
# Enhanced styling options
|
||||||
bold: bool = True # 굵은 글씨 (가독성 향상)
|
bold: bool = True # 굵은 글씨 (가독성 향상)
|
||||||
shadow: int = 1 # 그림자 깊이 (0=없음, 1-4)
|
shadow: int = 2 # 그림자 깊이 (0=없음, 1-4)
|
||||||
background_box: bool = True # 불투명 배경 박스로 원본 자막 덮기
|
background_box: bool = False # False=아웃라인 스타일 (깔끔함), True=배경 박스
|
||||||
background_opacity: str = "E0" # 배경 불투명도 (00=투명, FF=완전불투명, E0=권장)
|
background_opacity: str = "80" # 배경 불투명도 (00=투명, FF=완전불투명, 80=반투명)
|
||||||
animation: str = "none" # none, fade, pop (자막 애니메이션)
|
animation: str = "fade" # none, fade, pop (자막 애니메이션)
|
||||||
|
max_chars_per_line: int = 0 # 줄당 최대 글자 수 (0=비활성화, 15~20 권장)
|
||||||
|
|
||||||
|
|
||||||
class TranslationModeEnum(str, Enum):
|
class TranslationModeEnum(str, Enum):
|
||||||
@@ -68,11 +70,18 @@ class ProcessResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeRegion(BaseModel):
|
||||||
|
"""A region to exclude (cut out) from the video."""
|
||||||
|
start: float # Start time in seconds
|
||||||
|
end: float # End time in seconds
|
||||||
|
|
||||||
|
|
||||||
class TrimRequest(BaseModel):
|
class TrimRequest(BaseModel):
|
||||||
"""Request to trim a video to a specific time range."""
|
"""Request to trim a video to a specific time range."""
|
||||||
start_time: float # Start time in seconds
|
start_time: float # Start time in seconds
|
||||||
end_time: float # End time in seconds
|
end_time: float # End time in seconds
|
||||||
reprocess: bool = False # Whether to automatically reprocess after trimming (default: False for manual workflow)
|
reprocess: bool = False # Whether to automatically reprocess after trimming (default: False for manual workflow)
|
||||||
|
exclude_regions: List[ExcludeRegion] = [] # Regions to cut out from the middle of the video
|
||||||
|
|
||||||
|
|
||||||
class TranscribeRequest(BaseModel):
|
class TranscribeRequest(BaseModel):
|
||||||
@@ -91,6 +100,7 @@ class RenderRequest(BaseModel):
|
|||||||
intro_text: Optional[str] = None # Max 20 characters recommended
|
intro_text: Optional[str] = None # Max 20 characters recommended
|
||||||
intro_duration: float = 0.7 # Duration of frozen frame with intro text (seconds)
|
intro_duration: float = 0.7 # Duration of frozen frame with intro text (seconds)
|
||||||
intro_font_size: int = 100 # Font size
|
intro_font_size: int = 100 # Font size
|
||||||
|
intro_position: str = "center" # top, center, bottom
|
||||||
|
|
||||||
|
|
||||||
class TrimResponse(BaseModel):
|
class TrimResponse(BaseModel):
|
||||||
|
|||||||
@@ -116,12 +116,14 @@ async def process_task(
|
|||||||
outline_color=style.outline_color.lstrip("#"),
|
outline_color=style.outline_color.lstrip("#"),
|
||||||
font_name=style.font_name,
|
font_name=style.font_name,
|
||||||
position=style.position, # top, center, bottom
|
position=style.position, # top, center, bottom
|
||||||
|
margin_v=style.margin_v,
|
||||||
outline_width=style.outline_width,
|
outline_width=style.outline_width,
|
||||||
bold=style.bold,
|
bold=style.bold,
|
||||||
shadow=style.shadow,
|
shadow=style.shadow,
|
||||||
background_box=style.background_box,
|
background_box=style.background_box,
|
||||||
background_opacity=style.background_opacity,
|
background_opacity=style.background_opacity,
|
||||||
animation=style.animation,
|
animation=style.animation,
|
||||||
|
max_chars_per_line=style.max_chars_per_line,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save subtitle file
|
# Save subtitle file
|
||||||
@@ -284,12 +286,14 @@ async def continue_processing_task(
|
|||||||
outline_color=style.outline_color.lstrip("#"),
|
outline_color=style.outline_color.lstrip("#"),
|
||||||
font_name=style.font_name,
|
font_name=style.font_name,
|
||||||
position=style.position,
|
position=style.position,
|
||||||
|
margin_v=style.margin_v,
|
||||||
outline_width=style.outline_width,
|
outline_width=style.outline_width,
|
||||||
bold=style.bold,
|
bold=style.bold,
|
||||||
shadow=style.shadow,
|
shadow=style.shadow,
|
||||||
background_box=style.background_box,
|
background_box=style.background_box,
|
||||||
background_opacity=style.background_opacity,
|
background_opacity=style.background_opacity,
|
||||||
animation=style.animation,
|
animation=style.animation,
|
||||||
|
max_chars_per_line=style.max_chars_per_line,
|
||||||
)
|
)
|
||||||
|
|
||||||
job_dir = os.path.dirname(job.video_path)
|
job_dir = os.path.dirname(job.video_path)
|
||||||
@@ -511,12 +515,21 @@ async def trim_job_video(
|
|||||||
video_ext = os.path.splitext(job.video_path)[1]
|
video_ext = os.path.splitext(job.video_path)[1]
|
||||||
trimmed_path = os.path.join(video_dir, f"trimmed{video_ext}")
|
trimmed_path = os.path.join(video_dir, f"trimmed{video_ext}")
|
||||||
|
|
||||||
|
# Convert exclude_regions to list of dicts for the function
|
||||||
|
exclude_regions = None
|
||||||
|
if request.exclude_regions:
|
||||||
|
exclude_regions = [
|
||||||
|
{'start': region.start, 'end': region.end}
|
||||||
|
for region in request.exclude_regions
|
||||||
|
]
|
||||||
|
|
||||||
# Perform trim
|
# Perform trim
|
||||||
success, message = await trim_video(
|
success, message = await trim_video(
|
||||||
job.video_path,
|
job.video_path,
|
||||||
trimmed_path,
|
trimmed_path,
|
||||||
request.start_time,
|
request.start_time,
|
||||||
request.end_time,
|
request.end_time,
|
||||||
|
exclude_regions,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
@@ -690,6 +703,7 @@ async def render_step_task(
|
|||||||
intro_text: str | None = None,
|
intro_text: str | None = None,
|
||||||
intro_duration: float = 0.7,
|
intro_duration: float = 0.7,
|
||||||
intro_font_size: int = 100,
|
intro_font_size: int = 100,
|
||||||
|
intro_position: str = "center",
|
||||||
):
|
):
|
||||||
"""Background task for final video rendering (subtitle composition + BGM + intro text)."""
|
"""Background task for final video rendering (subtitle composition + BGM + intro text)."""
|
||||||
job = job_store.get_job(job_id)
|
job = job_store.get_job(job_id)
|
||||||
@@ -715,6 +729,7 @@ async def render_step_task(
|
|||||||
outline_color=style.outline_color.lstrip("#"),
|
outline_color=style.outline_color.lstrip("#"),
|
||||||
font_name=style.font_name,
|
font_name=style.font_name,
|
||||||
position=style.position,
|
position=style.position,
|
||||||
|
margin_v=style.margin_v,
|
||||||
outline_width=style.outline_width,
|
outline_width=style.outline_width,
|
||||||
bold=style.bold,
|
bold=style.bold,
|
||||||
shadow=style.shadow,
|
shadow=style.shadow,
|
||||||
@@ -722,6 +737,7 @@ async def render_step_task(
|
|||||||
background_opacity=style.background_opacity,
|
background_opacity=style.background_opacity,
|
||||||
animation=style.animation,
|
animation=style.animation,
|
||||||
time_offset=subtitle_offset,
|
time_offset=subtitle_offset,
|
||||||
|
max_chars_per_line=style.max_chars_per_line,
|
||||||
)
|
)
|
||||||
|
|
||||||
job_dir = os.path.dirname(job.video_path)
|
job_dir = os.path.dirname(job.video_path)
|
||||||
@@ -756,6 +772,7 @@ async def render_step_task(
|
|||||||
intro_text=intro_text,
|
intro_text=intro_text,
|
||||||
intro_duration=intro_duration,
|
intro_duration=intro_duration,
|
||||||
intro_font_size=intro_font_size,
|
intro_font_size=intro_font_size,
|
||||||
|
intro_position=intro_position,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -846,6 +863,7 @@ async def render_video(
|
|||||||
request.intro_text,
|
request.intro_text,
|
||||||
request.intro_duration,
|
request.intro_duration,
|
||||||
request.intro_font_size,
|
request.intro_font_size,
|
||||||
|
request.intro_position,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProcessResponse(
|
return ProcessResponse(
|
||||||
|
|||||||
@@ -314,21 +314,68 @@ def format_srt_time(seconds: float) -> str:
|
|||||||
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def auto_wrap_text(text: str, max_chars: int) -> str:
|
||||||
|
"""
|
||||||
|
자동으로 긴 텍스트를 2줄로 나눔.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 원본 텍스트
|
||||||
|
max_chars: 줄당 최대 글자 수 (0이면 비활성화)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
줄바꿈이 적용된 텍스트 (\\N 사용)
|
||||||
|
"""
|
||||||
|
if max_chars <= 0 or len(text) <= max_chars:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 이미 수동 줄바꿈이 있으면 그대로 반환 (\N, \n, /N, /n 모두 체크)
|
||||||
|
if "\\N" in text or "\\n" in text or "/N" in text or "/n" in text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# 중간 지점 근처에서 좋은 끊김점 찾기
|
||||||
|
mid = len(text) // 2
|
||||||
|
best_break = mid
|
||||||
|
|
||||||
|
# 공백, 쉼표, 마침표 등에서 끊기 우선
|
||||||
|
break_chars = [' ', ',', '.', '!', '?', '。', ',', '!', '?', '、']
|
||||||
|
|
||||||
|
# 중간점에서 가장 가까운 끊김점 찾기 (앞뒤 10자 범위)
|
||||||
|
for offset in range(min(10, mid)):
|
||||||
|
# 중간 뒤쪽 확인
|
||||||
|
if mid + offset < len(text) and text[mid + offset] in break_chars:
|
||||||
|
best_break = mid + offset + 1
|
||||||
|
break
|
||||||
|
# 중간 앞쪽 확인
|
||||||
|
if mid - offset >= 0 and text[mid - offset] in break_chars:
|
||||||
|
best_break = mid - offset + 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# 끊김점이 없으면 그냥 중간에서 자르기
|
||||||
|
line1 = text[:best_break].strip()
|
||||||
|
line2 = text[best_break:].strip()
|
||||||
|
|
||||||
|
if line2:
|
||||||
|
return f"{line1}\\N{line2}"
|
||||||
|
return line1
|
||||||
|
|
||||||
|
|
||||||
def segments_to_ass(
|
def segments_to_ass(
|
||||||
segments: List[TranscriptSegment],
|
segments: List[TranscriptSegment],
|
||||||
use_translated: bool = True,
|
use_translated: bool = True,
|
||||||
font_size: int = 28,
|
font_size: int = 70,
|
||||||
font_color: str = "FFFFFF",
|
font_color: str = "FFFFFF",
|
||||||
outline_color: str = "000000",
|
outline_color: str = "000000",
|
||||||
font_name: str = "NanumGothic",
|
font_name: str = "Pretendard",
|
||||||
position: str = "bottom", # top, center, bottom
|
position: str = "center", # top, center, bottom
|
||||||
outline_width: int = 3,
|
margin_v: int = 50, # 수직 위치 (0=가장자리, 100=화면 중심쪽)
|
||||||
|
outline_width: int = 4, # 아웃라인 두께 (가독성)
|
||||||
bold: bool = True,
|
bold: bool = True,
|
||||||
shadow: int = 1,
|
shadow: int = 2, # 그림자 깊이
|
||||||
background_box: bool = True,
|
background_box: bool = False, # False=아웃라인 스타일 (깔끔함)
|
||||||
background_opacity: str = "E0", # 00=transparent, FF=opaque
|
background_opacity: str = "80", # 00=transparent, FF=opaque
|
||||||
animation: str = "none", # none, fade, pop
|
animation: str = "fade", # none, fade, pop
|
||||||
time_offset: float = 0.0, # Delay all subtitles by this amount (for intro text)
|
time_offset: float = 0.0, # Delay all subtitles by this amount (for intro text)
|
||||||
|
max_chars_per_line: int = 0, # 줄당 최대 글자 수 (0=비활성화, 15~20 권장)
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Convert segments to ASS format with styling.
|
Convert segments to ASS format with styling.
|
||||||
@@ -341,6 +388,7 @@ def segments_to_ass(
|
|||||||
outline_color: Outline color in hex (without #)
|
outline_color: Outline color in hex (without #)
|
||||||
font_name: Font family name
|
font_name: Font family name
|
||||||
position: Subtitle position - "top", "center", or "bottom"
|
position: Subtitle position - "top", "center", or "bottom"
|
||||||
|
margin_v: Vertical margin (0=edge, 100=toward center) - percentage of screen height
|
||||||
outline_width: Outline thickness
|
outline_width: Outline thickness
|
||||||
bold: Use bold text
|
bold: Use bold text
|
||||||
shadow: Shadow depth (0-4)
|
shadow: Shadow depth (0-4)
|
||||||
@@ -355,16 +403,14 @@ def segments_to_ass(
|
|||||||
# 1=Bottom-Left, 2=Bottom-Center, 3=Bottom-Right
|
# 1=Bottom-Left, 2=Bottom-Center, 3=Bottom-Right
|
||||||
# 4=Middle-Left, 5=Middle-Center, 6=Middle-Right
|
# 4=Middle-Left, 5=Middle-Center, 6=Middle-Right
|
||||||
# 7=Top-Left, 8=Top-Center, 9=Top-Right
|
# 7=Top-Left, 8=Top-Center, 9=Top-Right
|
||||||
alignment_map = {
|
#
|
||||||
"top": 8, # Top-Center
|
# position='top'으로 고정하고 margin_v를 화면 높이의 퍼센트로 직접 사용
|
||||||
"center": 5, # Middle-Center (영상 가운데)
|
# margin_v=5 → 상단 5%, margin_v=95 → 하단 95%
|
||||||
"bottom": 2, # Bottom-Center (기본값)
|
alignment = 8 # Top-Center (상단 기준으로 margin_v 적용)
|
||||||
}
|
|
||||||
alignment = alignment_map.get(position, 2)
|
|
||||||
|
|
||||||
# Adjust margin based on position (낮은 값 = 화면 가장자리에 더 가까움)
|
# margin_v를 화면 높이의 퍼센트로 직접 변환 (1920 높이 기준)
|
||||||
# 원본 자막을 덮기 위해 하단 마진을 작게 설정
|
# margin_v=5 → 96px, margin_v=50 → 960px, margin_v=95 → 1824px
|
||||||
margin_v = 30 if position == "bottom" else (100 if position == "top" else 10)
|
ass_margin_v = int((margin_v / 100) * 1920)
|
||||||
|
|
||||||
# Bold: -1 = bold, 0 = normal
|
# Bold: -1 = bold, 0 = normal
|
||||||
bold_value = -1 if bold else 0
|
bold_value = -1 if bold else 0
|
||||||
@@ -385,7 +431,7 @@ PlayResY: 1920
|
|||||||
|
|
||||||
[V4+ Styles]
|
[V4+ Styles]
|
||||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
Style: Default,{font_name},{font_size},&H00{font_color},&H00FFFFFF,&H00{outline_color},&H{back_alpha}000000,{bold_value},0,0,0,100,100,0,0,{border_style},{outline_width},{shadow},{alignment},30,30,{margin_v},1
|
Style: Default,{font_name},{font_size},&H00{font_color},&H00FFFFFF,&H00{outline_color},&H{back_alpha}000000,{bold_value},0,0,0,100,100,0,0,{border_style},{outline_width},{shadow},{alignment},30,30,{ass_margin_v},1
|
||||||
|
|
||||||
[Events]
|
[Events]
|
||||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
@@ -396,10 +442,23 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
start_time = format_ass_time(seg.start + time_offset)
|
start_time = format_ass_time(seg.start + time_offset)
|
||||||
end_time = format_ass_time(seg.end + time_offset)
|
end_time = format_ass_time(seg.end + time_offset)
|
||||||
text = seg.translated if use_translated and seg.translated else seg.text
|
text = seg.translated if use_translated and seg.translated else seg.text
|
||||||
# Escape special characters
|
|
||||||
|
# 1. 자동 줄바꿈 적용 (max_chars_per_line이 설정된 경우)
|
||||||
|
if max_chars_per_line > 0:
|
||||||
|
text = auto_wrap_text(text, max_chars_per_line)
|
||||||
|
|
||||||
|
# 2. 수동 줄바꿈 처리: \N, \n, /N, /n을 모두 지원
|
||||||
|
# 사용자가 /N (슬래시)를 입력해도 동작하도록 함
|
||||||
|
text = text.replace("/N", "<<LINEBREAK>>").replace("/n", "<<LINEBREAK>>")
|
||||||
|
text = text.replace("\\N", "<<LINEBREAK>>").replace("\\n", "<<LINEBREAK>>")
|
||||||
|
|
||||||
|
# 3. Escape special characters (백슬래시, 중괄호)
|
||||||
text = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
|
text = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
|
||||||
|
|
||||||
# Add animation effects
|
# 4. 플레이스홀더를 ASS 줄바꿈으로 복원
|
||||||
|
text = text.replace("<<LINEBREAK>>", "\\N")
|
||||||
|
|
||||||
|
# 5. Add animation effects
|
||||||
if animation == "fade":
|
if animation == "fade":
|
||||||
# Fade in/out effect (250ms)
|
# Fade in/out effect (250ms)
|
||||||
text = f"{{\\fad(250,250)}}{text}"
|
text = f"{{\\fad(250,250)}}{text}"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ async def process_video(
|
|||||||
intro_text: Optional[str] = None,
|
intro_text: Optional[str] = None,
|
||||||
intro_duration: float = 0.7,
|
intro_duration: float = 0.7,
|
||||||
intro_font_size: int = 100,
|
intro_font_size: int = 100,
|
||||||
|
intro_position: str = "center", # top, center, bottom
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Process video: remove audio, add subtitles, add BGM, add intro text.
|
Process video: remove audio, add subtitles, add BGM, add intro text.
|
||||||
@@ -157,13 +158,21 @@ async def process_video(
|
|||||||
|
|
||||||
escaped_text = display_text.replace("'", "\\'").replace(":", "\\:")
|
escaped_text = display_text.replace("'", "\\'").replace(":", "\\:")
|
||||||
|
|
||||||
# Draw text centered on screen during freeze frame
|
# Calculate vertical position based on intro_position
|
||||||
|
if intro_position == "top":
|
||||||
|
y_expr = "h*0.15" # 15% from top
|
||||||
|
elif intro_position == "bottom":
|
||||||
|
y_expr = "h*0.80-text_h" # 80% from top (above subtitle area)
|
||||||
|
else: # center
|
||||||
|
y_expr = "(h-text_h)/2" # Center vertically
|
||||||
|
|
||||||
|
# Draw text on screen during freeze frame
|
||||||
drawtext_parts = [
|
drawtext_parts = [
|
||||||
f"text='{escaped_text}'",
|
f"text='{escaped_text}'",
|
||||||
f"fontsize={adjusted_font_size}",
|
f"fontsize={adjusted_font_size}",
|
||||||
"fontcolor=white",
|
"fontcolor=white",
|
||||||
"x=(w-text_w)/2", # Center horizontally
|
"x=(w-text_w)/2", # Center horizontally
|
||||||
"y=(h-text_h)/2", # Center vertically
|
f"y={y_expr}", # Vertical position based on intro_position
|
||||||
f"enable='lt(t,{intro_duration})'",
|
f"enable='lt(t,{intro_duration})'",
|
||||||
"borderw=4",
|
"borderw=4",
|
||||||
"bordercolor=black",
|
"bordercolor=black",
|
||||||
@@ -311,15 +320,17 @@ async def trim_video(
|
|||||||
output_path: str,
|
output_path: str,
|
||||||
start_time: float,
|
start_time: float,
|
||||||
end_time: float,
|
end_time: float,
|
||||||
|
exclude_regions: list = None,
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Trim video to specified time range.
|
Trim video to specified time range, optionally excluding middle sections.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
input_path: Path to input video
|
input_path: Path to input video
|
||||||
output_path: Path for output video
|
output_path: Path for output video
|
||||||
start_time: Start time in seconds
|
start_time: Start time in seconds
|
||||||
end_time: End time in seconds
|
end_time: End time in seconds
|
||||||
|
exclude_regions: List of dicts with 'start' and 'end' keys for sections to remove
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
@@ -341,6 +352,13 @@ async def trim_video(
|
|||||||
|
|
||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
|
||||||
|
# If there are exclude regions, use the complex concat approach
|
||||||
|
if exclude_regions and len(exclude_regions) > 0:
|
||||||
|
return await _trim_with_exclude_regions(
|
||||||
|
input_path, output_path, start_time, end_time, exclude_regions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simple trim without exclude regions
|
||||||
trim_duration = end_time - start_time
|
trim_duration = end_time - start_time
|
||||||
|
|
||||||
# Log trim parameters for debugging
|
# Log trim parameters for debugging
|
||||||
@@ -400,6 +418,138 @@ async def trim_video(
|
|||||||
return False, f"Trim error: {str(e)}"
|
return False, f"Trim error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _trim_with_exclude_regions(
|
||||||
|
input_path: str,
|
||||||
|
output_path: str,
|
||||||
|
start_time: float,
|
||||||
|
end_time: float,
|
||||||
|
exclude_regions: list,
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Trim video with exclude regions - cuts out specified sections and concatenates remaining parts.
|
||||||
|
|
||||||
|
Uses FFmpeg's filter_complex with trim and concat filters.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
print(f"[Trim] Trimming with {len(exclude_regions)} exclude regions")
|
||||||
|
print(f"[Trim] Main range: {start_time:.3f}s - {end_time:.3f}s")
|
||||||
|
for i, region in enumerate(exclude_regions):
|
||||||
|
print(f"[Trim] Exclude region {i}: {region['start']:.3f}s - {region['end']:.3f}s")
|
||||||
|
|
||||||
|
# Sort and merge overlapping exclude regions
|
||||||
|
sorted_regions = sorted(exclude_regions, key=lambda r: r['start'])
|
||||||
|
merged_regions = []
|
||||||
|
for region in sorted_regions:
|
||||||
|
# Clip region to main trim range
|
||||||
|
region_start = max(region['start'], start_time)
|
||||||
|
region_end = min(region['end'], end_time)
|
||||||
|
if region_start >= region_end:
|
||||||
|
continue # Skip invalid regions
|
||||||
|
|
||||||
|
if merged_regions and region_start <= merged_regions[-1]['end']:
|
||||||
|
merged_regions[-1]['end'] = max(merged_regions[-1]['end'], region_end)
|
||||||
|
else:
|
||||||
|
merged_regions.append({'start': region_start, 'end': region_end})
|
||||||
|
|
||||||
|
if not merged_regions:
|
||||||
|
# No valid exclude regions, use simple trim
|
||||||
|
print("[Trim] No valid exclude regions after merging, using simple trim")
|
||||||
|
return await trim_video(input_path, output_path, start_time, end_time, None)
|
||||||
|
|
||||||
|
# Calculate keep segments (inverse of exclude regions)
|
||||||
|
keep_segments = []
|
||||||
|
current_pos = start_time
|
||||||
|
|
||||||
|
for region in merged_regions:
|
||||||
|
if current_pos < region['start']:
|
||||||
|
keep_segments.append({'start': current_pos, 'end': region['start']})
|
||||||
|
current_pos = region['end']
|
||||||
|
|
||||||
|
# Add final segment if there's remaining time
|
||||||
|
if current_pos < end_time:
|
||||||
|
keep_segments.append({'start': current_pos, 'end': end_time})
|
||||||
|
|
||||||
|
if not keep_segments:
|
||||||
|
return False, "No video segments remaining after excluding regions"
|
||||||
|
|
||||||
|
print(f"[Trim] Keep segments: {keep_segments}")
|
||||||
|
|
||||||
|
# Calculate expected output duration
|
||||||
|
expected_duration = sum(seg['end'] - seg['start'] for seg in keep_segments)
|
||||||
|
print(f"[Trim] Expected output duration: {expected_duration:.3f}s")
|
||||||
|
|
||||||
|
# Build FFmpeg filter_complex for concatenation
|
||||||
|
# Each segment needs: trim, setpts for video; atrim, asetpts for audio
|
||||||
|
video_filters = []
|
||||||
|
audio_filters = []
|
||||||
|
segment_labels = []
|
||||||
|
|
||||||
|
for i, seg in enumerate(keep_segments):
|
||||||
|
seg_duration = seg['end'] - seg['start']
|
||||||
|
# Video filter: trim and reset timestamps
|
||||||
|
video_filters.append(
|
||||||
|
f"[0:v]trim=start={seg['start']:.6f}:end={seg['end']:.6f},setpts=PTS-STARTPTS[v{i}]"
|
||||||
|
)
|
||||||
|
# Audio filter: atrim and reset timestamps
|
||||||
|
audio_filters.append(
|
||||||
|
f"[0:a]atrim=start={seg['start']:.6f}:end={seg['end']:.6f},asetpts=PTS-STARTPTS[a{i}]"
|
||||||
|
)
|
||||||
|
segment_labels.append(f"[v{i}][a{i}]")
|
||||||
|
|
||||||
|
# Concat filter
|
||||||
|
concat_input = "".join(segment_labels)
|
||||||
|
filter_complex = ";".join(video_filters + audio_filters)
|
||||||
|
filter_complex += f";{concat_input}concat=n={len(keep_segments)}:v=1:a=1[outv][outa]"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", input_path,
|
||||||
|
"-filter_complex", filter_complex,
|
||||||
|
"-map", "[outv]",
|
||||||
|
"-map", "[outa]",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "fast",
|
||||||
|
"-crf", "18",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-b:a", "128k",
|
||||||
|
"-avoid_negative_ts", "make_zero",
|
||||||
|
output_path
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"[Trim] Command: ffmpeg -y -i {input_path} -filter_complex [complex] -map [outv] -map [outa] ...")
|
||||||
|
print(f"[Trim] Filter complex: {filter_complex[:200]}..." if len(filter_complex) > 200 else f"[Trim] Filter complex: {filter_complex}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300, # Longer timeout for complex operations
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error_msg = result.stderr[-500:] if result.stderr else "Unknown error"
|
||||||
|
print(f"[Trim] FFmpeg error: {error_msg}")
|
||||||
|
return False, f"Trim with exclude regions failed: {error_msg}"
|
||||||
|
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
new_duration = await get_video_duration(output_path)
|
||||||
|
print(f"[Trim] Success! New duration: {new_duration:.3f}s (expected: {expected_duration:.3f}s)")
|
||||||
|
return True, f"Video trimmed successfully ({new_duration:.1f}s, excluded {len(merged_regions)} regions)"
|
||||||
|
else:
|
||||||
|
print("[Trim] Error: Output file not created")
|
||||||
|
return False, "Output file not created"
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("[Trim] Error: Timeout")
|
||||||
|
return False, "Trim operation timed out"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Trim] Error: {str(e)}")
|
||||||
|
return False, f"Trim error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
async def extract_frame(
|
async def extract_frame(
|
||||||
video_path: str,
|
video_path: str,
|
||||||
output_path: str,
|
output_path: str,
|
||||||
|
|||||||
@@ -6,7 +6,14 @@
|
|||||||
<title>Shorts Maker - 쇼츠 한글 자막 변환기</title>
|
<title>Shorts Maker - 쇼츠 한글 자막 변환기</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<!-- 기본 UI 폰트 -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<!-- 자막 프리뷰용 한글 폰트들 (Google Fonts) -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&family=Nanum+Gothic+Coding&family=Black+Han+Sans&family=Do+Hyeon&family=Jua&family=Sunflower:wght@300;500;700&family=Gaegu:wght@300;400;700&family=Gamja+Flower&family=Stylish&family=Gothic+A1:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<!-- Pretendard (CDN) -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css">
|
||||||
|
<!-- Spoqa Han Sans (CDN) -->
|
||||||
|
<link href="https://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSansNeo.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const processApi = {
|
|||||||
intro_text: options.introText || null,
|
intro_text: options.introText || null,
|
||||||
intro_duration: options.introDuration || 0.7,
|
intro_duration: options.introDuration || 0.7,
|
||||||
intro_font_size: options.introFontSize || 100,
|
intro_font_size: options.introFontSize || 100,
|
||||||
|
intro_position: options.introPosition || 'center',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// === Other APIs ===
|
// === Other APIs ===
|
||||||
@@ -79,11 +80,13 @@ export const processApi = {
|
|||||||
// Get frame at specific timestamp (for precise trimming preview)
|
// Get frame at specific timestamp (for precise trimming preview)
|
||||||
getFrameUrl: (jobId, timestamp) => `${API_BASE}/process/${jobId}/frame?timestamp=${timestamp}`,
|
getFrameUrl: (jobId, timestamp) => `${API_BASE}/process/${jobId}/frame?timestamp=${timestamp}`,
|
||||||
// Trim video (reprocess default: false for manual workflow)
|
// Trim video (reprocess default: false for manual workflow)
|
||||||
trim: (jobId, startTime, endTime, reprocess = false) =>
|
// excludeRegions: array of {start, end} objects to cut from middle of video
|
||||||
|
trim: (jobId, startTime, endTime, reprocess = false, excludeRegions = []) =>
|
||||||
client.post(`/process/${jobId}/trim`, {
|
client.post(`/process/${jobId}/trim`, {
|
||||||
start_time: startTime,
|
start_time: startTime,
|
||||||
end_time: endTime,
|
end_time: endTime,
|
||||||
reprocess,
|
reprocess,
|
||||||
|
exclude_regions: excludeRegions,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
317
frontend/src/components/TimelineEditor.jsx
Normal file
317
frontend/src/components/TimelineEditor.jsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Play, Pause, Edit2, Check, X, Clock, Type, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { processApi, jobsApi } from '../api/client';
|
||||||
|
|
||||||
|
export default function TimelineEditor({ job, onTranscriptUpdate, onSave }) {
|
||||||
|
const [segments, setSegments] = useState(job.transcript || []);
|
||||||
|
const [editingIndex, setEditingIndex] = useState(null);
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [activeSegmentIndex, setActiveSegmentIndex] = useState(0);
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
const timelineRef = useRef(null);
|
||||||
|
|
||||||
|
// Sync video time with active segment
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
const time = videoRef.current.currentTime;
|
||||||
|
setCurrentTime(time);
|
||||||
|
|
||||||
|
// Find which segment is currently playing
|
||||||
|
const index = segments.findIndex(
|
||||||
|
(seg, i) => time >= seg.start && time < seg.end
|
||||||
|
);
|
||||||
|
if (index !== -1 && index !== activeSegmentIndex) {
|
||||||
|
setActiveSegmentIndex(index);
|
||||||
|
scrollToSegment(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
videoRef.current.addEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
return () => videoRef.current?.removeEventListener('timeupdate', handleTimeUpdate);
|
||||||
|
}
|
||||||
|
}, [segments, activeSegmentIndex]);
|
||||||
|
|
||||||
|
const scrollToSegment = (index) => {
|
||||||
|
const segmentEl = document.getElementById(`segment-${index}`);
|
||||||
|
if (segmentEl && timelineRef.current) {
|
||||||
|
segmentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (seconds) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
const ms = Math.floor((seconds % 1) * 100);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSegmentClick = (index) => {
|
||||||
|
setActiveSegmentIndex(index);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = segments[index].start;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditStart = (index) => {
|
||||||
|
setEditingIndex(index);
|
||||||
|
setEditText(segments[index].translated || segments[index].text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = () => {
|
||||||
|
if (editingIndex === null) return;
|
||||||
|
|
||||||
|
const newSegments = [...segments];
|
||||||
|
newSegments[editingIndex] = {
|
||||||
|
...newSegments[editingIndex],
|
||||||
|
translated: editText,
|
||||||
|
};
|
||||||
|
setSegments(newSegments);
|
||||||
|
setEditingIndex(null);
|
||||||
|
setEditText('');
|
||||||
|
|
||||||
|
if (onTranscriptUpdate) {
|
||||||
|
onTranscriptUpdate(newSegments);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setEditingIndex(null);
|
||||||
|
setEditText('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevSegment = () => {
|
||||||
|
if (activeSegmentIndex > 0) {
|
||||||
|
const newIndex = activeSegmentIndex - 1;
|
||||||
|
setActiveSegmentIndex(newIndex);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = segments[newIndex].start;
|
||||||
|
}
|
||||||
|
scrollToSegment(newIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextSegment = () => {
|
||||||
|
if (activeSegmentIndex < segments.length - 1) {
|
||||||
|
const newIndex = activeSegmentIndex + 1;
|
||||||
|
setActiveSegmentIndex(newIndex);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = segments[newIndex].start;
|
||||||
|
}
|
||||||
|
scrollToSegment(newIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAll = async () => {
|
||||||
|
try {
|
||||||
|
await processApi.updateTranscript(job.job_id, segments);
|
||||||
|
if (onSave) {
|
||||||
|
onSave(segments);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save transcript:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get video URL
|
||||||
|
const videoUrl = jobsApi.downloadOriginal(job.job_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||||
|
<Type size={20} className="text-purple-400" />
|
||||||
|
타임라인 편집기
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<Check size={16} />
|
||||||
|
스크립트 저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Editor Layout - Vrew Style */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Left: Video Preview */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Video Player */}
|
||||||
|
<div className="relative bg-black rounded-lg overflow-hidden aspect-[9/16] max-h-[400px] mx-auto">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoUrl}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Current subtitle overlay */}
|
||||||
|
{segments[activeSegmentIndex] && (
|
||||||
|
<div className="absolute bottom-16 left-0 right-0 text-center px-4">
|
||||||
|
<span className="bg-black/80 text-white px-3 py-1 rounded text-sm">
|
||||||
|
{segments[activeSegmentIndex].translated || segments[activeSegmentIndex].text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-4 bg-gray-800 rounded-lg p-3">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevSegment}
|
||||||
|
disabled={activeSegmentIndex === 0}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded-lg disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
className="p-3 bg-purple-600 hover:bg-purple-500 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={24} /> : <Play size={24} className="ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNextSegment}
|
||||||
|
disabled={activeSegmentIndex === segments.length - 1}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded-lg disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time indicator */}
|
||||||
|
<div className="text-center text-sm text-gray-400">
|
||||||
|
<Clock size={14} className="inline mr-1" />
|
||||||
|
{formatTime(currentTime)} / 세그먼트 {activeSegmentIndex + 1} / {segments.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Timeline Segments List */}
|
||||||
|
<div
|
||||||
|
ref={timelineRef}
|
||||||
|
className="bg-gray-800/50 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-3 border-b border-gray-700 sticky top-0 bg-gray-800 z-10">
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-400">
|
||||||
|
<span>타임라인 ({segments.length}개 세그먼트)</span>
|
||||||
|
<span className="text-xs">클릭하여 이동, 더블클릭하여 편집</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[500px] overflow-y-auto">
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
id={`segment-${index}`}
|
||||||
|
onClick={() => handleSegmentClick(index)}
|
||||||
|
onDoubleClick={() => handleEditStart(index)}
|
||||||
|
className={`flex gap-3 p-3 border-b border-gray-700/50 cursor-pointer transition-colors ${
|
||||||
|
activeSegmentIndex === index
|
||||||
|
? 'bg-purple-900/30 border-l-4 border-l-purple-500'
|
||||||
|
: 'hover:bg-gray-700/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Segment Number */}
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-sm font-medium">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="flex-shrink-0 w-16 h-12 bg-gray-900 rounded overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={processApi.getFrameUrl(job.job_id, segment.start)}
|
||||||
|
alt={`Frame at ${segment.start}s`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Time */}
|
||||||
|
<div className="text-xs text-gray-500 mb-1 flex items-center gap-2">
|
||||||
|
<Clock size={10} />
|
||||||
|
{formatTime(segment.start)} - {formatTime(segment.end)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
{editingIndex === index ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
className="w-full p-2 bg-gray-900 border border-purple-500 rounded text-sm resize-none focus:outline-none"
|
||||||
|
rows={2}
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEditSave(); }}
|
||||||
|
className="px-2 py-1 bg-green-600 hover:bg-green-500 rounded text-xs flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Check size={12} /> 저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEditCancel(); }}
|
||||||
|
className="px-2 py-1 bg-gray-600 hover:bg-gray-500 rounded text-xs flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<X size={12} /> 취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{/* Original text (Chinese) */}
|
||||||
|
{segment.text !== segment.translated && (
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{segment.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Translated text (Korean) */}
|
||||||
|
<p className="text-sm text-white line-clamp-2">
|
||||||
|
{segment.translated || segment.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Button */}
|
||||||
|
{editingIndex !== index && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEditStart(index); }}
|
||||||
|
className="flex-shrink-0 p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tip */}
|
||||||
|
<div className="text-xs text-gray-500 text-center">
|
||||||
|
팁: 세그먼트를 클릭하면 해당 시간으로 이동합니다. 더블클릭하거나 편집 버튼을 눌러 텍스트를 수정하세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2224
frontend/src/components/VideoStudio.jsx
Normal file
2224
frontend/src/components/VideoStudio.jsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -89,3 +89,64 @@ body {
|
|||||||
.animate-slideIn {
|
.animate-slideIn {
|
||||||
animation: slideIn 0.4s ease-out forwards;
|
animation: slideIn 0.4s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dual-handle range slider */
|
||||||
|
.dual-range-slider input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-range-slider input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 24px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-range-slider input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 24px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide default track */
|
||||||
|
.dual-range-slider input[type="range"]::-webkit-slider-runnable-track {
|
||||||
|
background: transparent;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-range-slider input[type="range"]::-moz-range-track {
|
||||||
|
background: transparent;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range slider accent color */
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
cursor: grab;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f59e0b;
|
||||||
|
cursor: grab;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user