feat(rework): synced subtitle extraction via file upload + Whisper, SRT export
Phase 1 of subtitle timeline studio. Upload a video in the rework editor →
proxy to Python /transcribe (faster-whisper) → store segments → render a
playback-synced segment list and export CapCut-importable SRT.
- ScriptSegment + SrtFormatter (pure, unit-tested) with speed rescaling
- ChannelVideoScript.segmentsJson column (ddl-auto adds it)
- ChannelService.transcribeFromFile + getSegments/parseSegments
- POST /{id}/transcribe (multipart), GET /{id}/script.srt?speed=, /script now returns segments
- rework.html: upload button, local <video>, segment list, SRT export + speed
- multipart 200MB limit; python.base-url config (PYTHON_BASE_URL)
- repo: findFirstByVideoIdOrderByIdDesc/findAllByVideoId (dedup-safe lookups)
Spec: docs/superpowers/specs/2026-06-12-subtitle-timeline-studio-design.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa7cec7f14
commit
b9aa04d4a3
@ -2,16 +2,28 @@ package com.hlab.yanalyst.domain.channel;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.ByteArrayResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -31,6 +43,9 @@ public class ChannelService {
|
|||||||
@Value("${youtube.api.key}") // application.yml(youtube.api.key) → 환경변수 YOUTUBE_API_KEY 오버라이드
|
@Value("${youtube.api.key}") // application.yml(youtube.api.key) → 환경변수 YOUTUBE_API_KEY 오버라이드
|
||||||
private String youtubeApiKey;
|
private String youtubeApiKey;
|
||||||
|
|
||||||
|
@Value("${python.base-url:http://h-python.tolag.shop}") // Python 마이크로서비스(자막/전사) 베이스 URL
|
||||||
|
private String pythonBaseUrl;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Channel saveChannelFromUrl(String url) {
|
public Channel saveChannelFromUrl(String url) {
|
||||||
String identifier = extractIdentifier(url);
|
String identifier = extractIdentifier(url);
|
||||||
@ -182,8 +197,8 @@ public class ChannelService {
|
|||||||
public void deleteChannel(Long id) {
|
public void deleteChannel(Long id) {
|
||||||
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(id);
|
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(id);
|
||||||
for (ChannelVideo video : videos) {
|
for (ChannelVideo video : videos) {
|
||||||
channelVideoScriptRepository.findByVideoId(video.getVideoId())
|
channelVideoScriptRepository.deleteAll(
|
||||||
.ifPresent(channelVideoScriptRepository::delete);
|
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||||
channelVideoRepository.delete(video);
|
channelVideoRepository.delete(video);
|
||||||
}
|
}
|
||||||
channelRepository.deleteById(id);
|
channelRepository.deleteById(id);
|
||||||
@ -327,7 +342,7 @@ public class ChannelService {
|
|||||||
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
||||||
|
|
||||||
String apiUrl = "http://h-python.tolag.shop/transcript";
|
String apiUrl = pythonBaseUrl + "/transcript";
|
||||||
// Construct standard YouTube URL from video ID
|
// Construct standard YouTube URL from video ID
|
||||||
String videoUrl = "https://www.youtube.com/watch?v=" + video.getVideoId();
|
String videoUrl = "https://www.youtube.com/watch?v=" + video.getVideoId();
|
||||||
|
|
||||||
@ -342,6 +357,10 @@ public class ChannelService {
|
|||||||
com.hlab.yanalyst.domain.production.dto.ScriptResponseDto scriptDto =
|
com.hlab.yanalyst.domain.production.dto.ScriptResponseDto scriptDto =
|
||||||
objectMapper.readValue(response.getBody(), com.hlab.yanalyst.domain.production.dto.ScriptResponseDto.class);
|
objectMapper.readValue(response.getBody(), com.hlab.yanalyst.domain.production.dto.ScriptResponseDto.class);
|
||||||
|
|
||||||
|
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
||||||
|
channelVideoScriptRepository.deleteAll(
|
||||||
|
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||||
|
|
||||||
ChannelVideoScript script = new ChannelVideoScript();
|
ChannelVideoScript script = new ChannelVideoScript();
|
||||||
script.setChannelVideoId(channelVideoId);
|
script.setChannelVideoId(channelVideoId);
|
||||||
script.setVideoId(video.getVideoId());
|
script.setVideoId(video.getVideoId());
|
||||||
@ -365,6 +384,99 @@ public class ChannelService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다.
|
||||||
|
* 평문 transcript 와 segments_json 을 함께 저장하고 hasScript=true 로 승격한다.
|
||||||
|
*
|
||||||
|
* @return 추출 결과 DTO(segments/transcript/language/duration)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file) {
|
||||||
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
||||||
|
|
||||||
|
String apiUrl = pythonBaseUrl + "/transcribe";
|
||||||
|
log.info("Requesting whisper transcription for video {} ({} bytes)", channelVideoId, file.getSize());
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
|
||||||
|
final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank())
|
||||||
|
? file.getOriginalFilename() : "upload.mp4";
|
||||||
|
Resource fileResource = new ByteArrayResource(file.getBytes()) {
|
||||||
|
@Override
|
||||||
|
public String getFilename() {
|
||||||
|
return filename; // multipart filename 보존
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
|
body.add("file", fileResource);
|
||||||
|
|
||||||
|
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
|
||||||
|
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, request, String.class);
|
||||||
|
|
||||||
|
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||||
|
throw new RuntimeException("Transcribe API failed with status: " + response.getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
||||||
|
|
||||||
|
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
||||||
|
channelVideoScriptRepository.deleteAll(
|
||||||
|
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||||
|
|
||||||
|
ChannelVideoScript script = new ChannelVideoScript();
|
||||||
|
script.setChannelVideoId(channelVideoId);
|
||||||
|
script.setVideoId(video.getVideoId());
|
||||||
|
script.setLanguage(dto.getLanguage());
|
||||||
|
script.setTranscript(dto.getTranscript());
|
||||||
|
script.setSegmentsJson(objectMapper.writeValueAsString(
|
||||||
|
dto.getSegments() == null ? Collections.emptyList() : dto.getSegments()));
|
||||||
|
channelVideoScriptRepository.save(script);
|
||||||
|
|
||||||
|
video.setHasScript(true);
|
||||||
|
channelVideoRepository.save(video);
|
||||||
|
|
||||||
|
log.info("Saved whisper transcript for channel video id: {} ({} segments)",
|
||||||
|
channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size());
|
||||||
|
return dto;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error transcribing uploaded file for video " + channelVideoId, e);
|
||||||
|
throw new RuntimeException("Error transcribing uploaded file", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최신 스크립트의 세그먼트 목록을 반환한다. 없으면 빈 리스트. */
|
||||||
|
public List<ScriptSegment> getSegments(Long channelVideoId) {
|
||||||
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
||||||
|
return channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(video.getVideoId())
|
||||||
|
.map(s -> parseSegments(s.getSegmentsJson()))
|
||||||
|
.orElseGet(Collections::emptyList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 세그먼트 JSON 문자열 → ScriptSegment 목록(파싱 실패/빈 값이면 빈 리스트). */
|
||||||
|
public List<ScriptSegment> parseSegments(String segmentsJson) {
|
||||||
|
if (segmentsJson == null || segmentsJson.isBlank()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<ScriptResponseDto.Segment> raw = objectMapper.readValue(
|
||||||
|
segmentsJson,
|
||||||
|
objectMapper.getTypeFactory().constructCollectionType(List.class, ScriptResponseDto.Segment.class));
|
||||||
|
List<ScriptSegment> result = new ArrayList<>(raw.size());
|
||||||
|
for (ScriptResponseDto.Segment s : raw) {
|
||||||
|
result.add(new ScriptSegment(s.getStart(), s.getEnd(), s.getText()));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse segments json", e);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void extractAllScripts(Long channelId) {
|
public void extractAllScripts(Long channelId) {
|
||||||
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(channelId);
|
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(channelId);
|
||||||
|
|||||||
@ -4,8 +4,13 @@ import com.hlab.yanalyst.global.common.ApiResponse;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -115,19 +120,43 @@ public class ChannelVideoCurationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/script")
|
@GetMapping("/{id}/script")
|
||||||
@Operation(summary = "원본 스크립트 조회", description = "추출된 transcript. 없으면 transcript=null.")
|
@Operation(summary = "원본 스크립트 조회",
|
||||||
|
description = "추출된 transcript(평문) + 영상 싱크 segments([{start,end,text}]). 없으면 transcript=\"\", segments=[].")
|
||||||
public ApiResponse<Map<String, Object>> getScript(@PathVariable Long id) {
|
public ApiResponse<Map<String, Object>> getScript(@PathVariable Long id) {
|
||||||
String t = curationService.getTranscript(id);
|
return ApiResponse.ok(curationService.getScriptData(id));
|
||||||
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/extract-script")
|
@PostMapping("/{id}/extract-script")
|
||||||
@Operation(summary = "원본 스크립트 추출", description = "외부 transcript 서비스로 자막을 추출해 저장한다.")
|
@Operation(summary = "원본 스크립트 추출(URL 자막)", description = "외부 transcript 서비스로 YouTube 자막을 추출해 저장한다.")
|
||||||
public ApiResponse<Map<String, Object>> extractScript(@PathVariable Long id) {
|
public ApiResponse<Map<String, Object>> extractScript(@PathVariable Long id) {
|
||||||
String t = curationService.extractTranscript(id);
|
String t = curationService.extractTranscript(id);
|
||||||
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
|
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@Operation(summary = "업로드 영상 전사(Whisper)",
|
||||||
|
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
|
||||||
|
+ "multipart 필드: file. 응답: {hasScript, language, duration, transcript, segments}.")
|
||||||
|
public ApiResponse<Map<String, Object>> transcribe(@PathVariable Long id,
|
||||||
|
@RequestParam("file") MultipartFile file) {
|
||||||
|
return ApiResponse.ok(curationService.transcribeFromFile(id, file));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/script.srt")
|
||||||
|
@Operation(summary = "SRT 자막 내보내기",
|
||||||
|
description = "저장된 세그먼트로 SRT 파일을 생성해 다운로드한다. speed(기본 1.0)로 배속 보정. CapCut import 용.")
|
||||||
|
public ResponseEntity<byte[]> exportSrt(@PathVariable Long id,
|
||||||
|
@RequestParam(defaultValue = "1.0") double speed) {
|
||||||
|
String srt = curationService.buildSrt(id, speed);
|
||||||
|
byte[] bytes = srt.getBytes(StandardCharsets.UTF_8);
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(new MediaType("application", "x-subrip", StandardCharsets.UTF_8));
|
||||||
|
headers.setContentDisposition(
|
||||||
|
org.springframework.http.ContentDisposition.attachment()
|
||||||
|
.filename("script_" + id + ".srt", StandardCharsets.UTF_8).build());
|
||||||
|
return ResponseEntity.ok().headers(headers).body(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/rework")
|
@PostMapping("/{id}/rework")
|
||||||
@Operation(summary = "재작성 초안 저장", description = "body: {\"reworkText\": \"...\"} — 저장 시 상태가 TARGET 으로 승격된다.")
|
@Operation(summary = "재작성 초안 저장", description = "body: {\"reworkText\": \"...\"} — 저장 시 상태가 TARGET 으로 승격된다.")
|
||||||
public ApiResponse<ChannelVideo> saveRework(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
public ApiResponse<ChannelVideo> saveRework(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
package com.hlab.yanalyst.domain.channel;
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,7 +73,7 @@ public class ChannelVideoCurationService {
|
|||||||
/** 원본 스크립트(transcript) 조회. 없으면 null. */
|
/** 원본 스크립트(transcript) 조회. 없으면 null. */
|
||||||
public String getTranscript(Long videoId) {
|
public String getTranscript(Long videoId) {
|
||||||
ChannelVideo v = find(videoId);
|
ChannelVideo v = find(videoId);
|
||||||
return channelVideoScriptRepository.findByVideoId(v.getVideoId())
|
return channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId())
|
||||||
.map(ChannelVideoScript::getTranscript)
|
.map(ChannelVideoScript::getTranscript)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
@ -79,11 +83,51 @@ public class ChannelVideoCurationService {
|
|||||||
public String extractTranscript(Long videoId) {
|
public String extractTranscript(Long videoId) {
|
||||||
ChannelVideo v = find(videoId);
|
ChannelVideo v = find(videoId);
|
||||||
channelService.extractScript(v.getId()); // channel_video_scripts 에 저장 + hasScript=true
|
channelService.extractScript(v.getId()); // channel_video_scripts 에 저장 + hasScript=true
|
||||||
return channelVideoScriptRepository.findByVideoId(v.getVideoId())
|
return channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId())
|
||||||
.map(ChannelVideoScript::getTranscript)
|
.map(ChannelVideoScript::getTranscript)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 스크립트 전체(평문 + 영상 싱크 세그먼트) 조회. 에디터의 세그먼트 리스트용. */
|
||||||
|
public Map<String, Object> getScriptData(Long videoId) {
|
||||||
|
ChannelVideo v = find(videoId);
|
||||||
|
String transcript = channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId())
|
||||||
|
.map(ChannelVideoScript::getTranscript)
|
||||||
|
.orElse(null);
|
||||||
|
String language = channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId())
|
||||||
|
.map(ChannelVideoScript::getLanguage)
|
||||||
|
.orElse(null);
|
||||||
|
List<ScriptSegment> segments = channelService.getSegments(v.getId());
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("hasScript", transcript != null);
|
||||||
|
result.put("language", language);
|
||||||
|
result.put("transcript", transcript == null ? "" : transcript);
|
||||||
|
result.put("segments", segments);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 업로드 영상 파일을 Whisper 로 전사해 영상 싱크 세그먼트를 저장하고 결과를 반환한다. */
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> transcribeFromFile(Long videoId, MultipartFile file) {
|
||||||
|
ChannelVideo v = find(videoId);
|
||||||
|
ScriptResponseDto dto = channelService.transcribeFromFile(v.getId(), file);
|
||||||
|
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("hasScript", true);
|
||||||
|
result.put("language", dto.getLanguage());
|
||||||
|
result.put("duration", dto.getDuration());
|
||||||
|
result.put("transcript", dto.getTranscript() == null ? "" : dto.getTranscript());
|
||||||
|
result.put("segments", channelService.getSegments(v.getId()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */
|
||||||
|
public String buildSrt(Long videoId, double speed) {
|
||||||
|
ChannelVideo v = find(videoId);
|
||||||
|
return SrtFormatter.toSrt(channelService.getSegments(v.getId()), speed);
|
||||||
|
}
|
||||||
|
|
||||||
/** 재작성 초안 저장 + 상태를 TARGET 으로 승격(아직 NEW/REVIEWING 이면). */
|
/** 재작성 초안 저장 + 상태를 TARGET 으로 승격(아직 NEW/REVIEWING 이면). */
|
||||||
@Transactional
|
@Transactional
|
||||||
public ChannelVideo saveRework(Long videoId, String text) {
|
public ChannelVideo saveRework(Long videoId, String text) {
|
||||||
@ -99,8 +143,8 @@ public class ChannelVideoCurationService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long videoId) {
|
public void delete(Long videoId) {
|
||||||
ChannelVideo video = find(videoId);
|
ChannelVideo video = find(videoId);
|
||||||
channelVideoScriptRepository.findByVideoId(video.getVideoId())
|
channelVideoScriptRepository.deleteAll(
|
||||||
.ifPresent(channelVideoScriptRepository::delete);
|
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||||
channelVideoRepository.delete(video);
|
channelVideoRepository.delete(video);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,10 @@ public class ChannelVideoScript {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String transcript;
|
private String transcript;
|
||||||
|
|
||||||
|
/** 영상 싱크 자막 세그먼트 JSON 배열([{start,end,text},...]). Whisper 전사 시 채워진다. */
|
||||||
|
@Column(name = "segments_json", columnDefinition = "TEXT")
|
||||||
|
private String segmentsJson;
|
||||||
|
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(name = "created_at", updatable = false)
|
@Column(name = "created_at", updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@ -2,9 +2,15 @@ package com.hlab.yanalyst.domain.channel;
|
|||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface ChannelVideoScriptRepository extends JpaRepository<ChannelVideoScript, Long> {
|
public interface ChannelVideoScriptRepository extends JpaRepository<ChannelVideoScript, Long> {
|
||||||
Optional<ChannelVideoScript> findByVideoId(String videoId);
|
/** 같은 videoId 에 중복 row 가 있어도 최신(id 큰) 1건만 안전하게 반환한다. */
|
||||||
|
Optional<ChannelVideoScript> findFirstByVideoIdOrderByIdDesc(String videoId);
|
||||||
|
|
||||||
|
/** videoId 에 해당하는 모든 스크립트(중복 포함). 정리/삭제용. */
|
||||||
|
List<ChannelVideoScript> findAllByVideoId(String videoId);
|
||||||
|
|
||||||
boolean existsByVideoId(String videoId);
|
boolean existsByVideoId(String videoId);
|
||||||
}
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자막 한 구간(세그먼트). 영상과 싱크되는 시작/끝 시각(초)과 텍스트.
|
||||||
|
* Whisper 전사 결과 및 SRT 생성의 공통 단위.
|
||||||
|
*/
|
||||||
|
public record ScriptSegment(double start, double end, String text) {
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세그먼트 목록을 SRT 자막 문자열로 변환하는 순수 유틸.
|
||||||
|
* CapCut 등 외부 편집기로 import 가능한 표준 SRT 를 생성한다.
|
||||||
|
*
|
||||||
|
* <p>{@code speed} 는 영상 배속을 의미한다. 예: 1.2배속이면 모든 타임스탬프를
|
||||||
|
* 1.2 로 나눠 자막이 빨라진 영상과 싱크되도록 보정한다.
|
||||||
|
*/
|
||||||
|
public final class SrtFormatter {
|
||||||
|
|
||||||
|
private SrtFormatter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String toSrt(List<ScriptSegment> segments, double speed) {
|
||||||
|
if (segments == null || segments.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
double factor = (speed > 0) ? speed : 1.0;
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int index = 1;
|
||||||
|
for (ScriptSegment seg : segments) {
|
||||||
|
if (seg == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double start = seg.start() / factor;
|
||||||
|
double end = seg.end() / factor;
|
||||||
|
if (start < 0) {
|
||||||
|
start = 0;
|
||||||
|
}
|
||||||
|
if (end < start) {
|
||||||
|
end = start;
|
||||||
|
}
|
||||||
|
sb.append(index++).append('\n')
|
||||||
|
.append(timecode(start)).append(" --> ").append(timecode(end)).append('\n')
|
||||||
|
.append(seg.text() == null ? "" : seg.text().trim()).append("\n\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 초(double) → SRT 타임코드 {@code HH:MM:SS,mmm}. */
|
||||||
|
private static String timecode(double seconds) {
|
||||||
|
long ms = Math.round(seconds * 1000.0);
|
||||||
|
long hours = ms / 3_600_000;
|
||||||
|
ms %= 3_600_000;
|
||||||
|
long minutes = ms / 60_000;
|
||||||
|
ms %= 60_000;
|
||||||
|
long secs = ms / 1000;
|
||||||
|
ms %= 1000;
|
||||||
|
return String.format("%02d:%02d:%02d,%03d", hours, minutes, secs, ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@ package com.hlab.yanalyst.domain.production.dto;
|
|||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class ScriptResponseDto {
|
public class ScriptResponseDto {
|
||||||
@JsonProperty("video_id")
|
@JsonProperty("video_id")
|
||||||
@ -10,4 +12,17 @@ public class ScriptResponseDto {
|
|||||||
|
|
||||||
private String language;
|
private String language;
|
||||||
private String transcript;
|
private String transcript;
|
||||||
|
|
||||||
|
/** 전체 길이(초) — /transcribe(Whisper) 응답에만 존재. */
|
||||||
|
private Double duration;
|
||||||
|
|
||||||
|
/** 영상과 싱크되는 자막 세그먼트 — /transcribe(Whisper) 응답에만 존재. */
|
||||||
|
private List<Segment> segments;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Segment {
|
||||||
|
private double start;
|
||||||
|
private double end;
|
||||||
|
private String text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,10 @@ spring:
|
|||||||
# application-local.yml (see application-local.yml.example). No secrets here.
|
# application-local.yml (see application-local.yml.example). No secrets here.
|
||||||
config:
|
config:
|
||||||
import: optional:classpath:application-local.yml
|
import: optional:classpath:application-local.yml
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: ${UPLOAD_MAX_FILE_SIZE:200MB} # 업로드 영상(Whisper 전사용)
|
||||||
|
max-request-size: ${UPLOAD_MAX_REQUEST_SIZE:200MB}
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL:}
|
url: ${DB_URL:}
|
||||||
driverClassName: org.postgresql.Driver
|
driverClassName: org.postgresql.Driver
|
||||||
@ -46,6 +50,10 @@ youtube:
|
|||||||
api:
|
api:
|
||||||
key: ${YOUTUBE_API_KEY:}
|
key: ${YOUTUBE_API_KEY:}
|
||||||
|
|
||||||
|
# Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드.
|
||||||
|
python:
|
||||||
|
base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop}
|
||||||
|
|
||||||
hlab:
|
hlab:
|
||||||
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
||||||
scheduler:
|
scheduler:
|
||||||
|
|||||||
@ -20,6 +20,8 @@
|
|||||||
<!-- 좌: 영상 + 정보 -->
|
<!-- 좌: 영상 + 정보 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div id="player" style="width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; overflow:hidden; background:#000; margin-bottom:12px;"></div>
|
<div id="player" style="width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; overflow:hidden; background:#000; margin-bottom:12px;"></div>
|
||||||
|
<video id="localVideo" controls playsinline
|
||||||
|
style="display:none; width:100%; aspect-ratio:9/16; max-height:480px; border-radius:8px; background:#000; margin-bottom:12px;"></video>
|
||||||
<div class="flex flex-col gap-1 text-sm">
|
<div class="flex flex-col gap-1 text-sm">
|
||||||
<div class="font-bold" id="vTitle" style="line-height:1.4;"></div>
|
<div class="font-bold" id="vTitle" style="line-height:1.4;"></div>
|
||||||
<div class="text-muted" id="vChannel"></div>
|
<div class="text-muted" id="vChannel"></div>
|
||||||
@ -32,20 +34,50 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<!-- 원본 스크립트 -->
|
<!-- 원본 스크립트 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:8px;">
|
||||||
<h3 class="text-lg font-bold">원본 스크립트</h3>
|
<h3 class="text-lg font-bold">원본 스크립트</h3>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()">
|
<label class="btn btn-primary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;">
|
||||||
<i data-lucide="download-cloud" style="width:15px;"></i> 추출
|
<i data-lucide="upload" style="width:15px;"></i> 영상 업로드·전사
|
||||||
|
<input type="file" id="videoFile" accept="video/*" style="display:none;" onchange="onVideoSelected(event)">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()" title="YouTube 자막에서 평문 추출(타임스탬프 없음)">
|
||||||
|
<i data-lucide="download-cloud" style="width:15px;"></i> URL자막
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
|
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
|
||||||
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="transcript" readonly placeholder="스크립트가 없습니다. [추출] 을 눌러 자막을 가져오세요."
|
|
||||||
style="width:100%; min-height:160px; resize:vertical; padding:12px; background:rgba(255,255,255,0.03); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:#cbd5e1; outline:none; font-size:0.9rem; line-height:1.6;"></textarea>
|
<div id="transcribeStatus" class="text-sm mb-2" style="display:none;"></div>
|
||||||
|
|
||||||
|
<!-- 영상 싱크 세그먼트 리스트 (CapCut형) -->
|
||||||
|
<div id="segmentList" style="display:none; max-height:340px; overflow:auto; border:1px solid var(--glass-border); border-radius:var(--radius-md); background:rgba(255,255,255,0.02);"></div>
|
||||||
|
|
||||||
|
<!-- 평문 폴백 (URL 자막 추출 시) -->
|
||||||
|
<textarea id="transcript" readonly placeholder="‘영상 업로드·전사’로 영상에서 싱크된 자막을 추출하거나, ‘URL자막’으로 YouTube 자막을 가져오세요."
|
||||||
|
style="width:100%; min-height:140px; resize:vertical; padding:12px; background:rgba(255,255,255,0.03); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:#cbd5e1; outline:none; font-size:0.9rem; line-height:1.6;"></textarea>
|
||||||
|
|
||||||
|
<!-- SRT 내보내기 -->
|
||||||
|
<div class="flex items-center gap-2 mt-3" id="srtBar" style="display:none; flex-wrap:wrap;">
|
||||||
|
<label class="text-sm text-muted">배속</label>
|
||||||
|
<input id="srtSpeed" type="number" value="1.0" step="0.1" min="0.1"
|
||||||
|
style="width:72px; padding:7px; background:rgba(255,255,255,0.05); border:1px solid var(--glass-border); border-radius:var(--radius-md); color:white; outline:none; font-size:0.9rem;">
|
||||||
|
<button class="btn btn-primary px-3 py-2 flex items-center gap-1" onclick="exportSrt()">
|
||||||
|
<i data-lucide="file-down" style="width:15px;"></i> SRT 내보내기
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-muted">CapCut import용 · 배속 적용 시 타임스탬프 자동 보정</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.seg-row { display:flex; gap:10px; padding:7px 12px; cursor:pointer; border-bottom:1px solid rgba(255,255,255,0.05); font-size:0.9rem; line-height:1.5; }
|
||||||
|
.seg-row:hover { background:rgba(255,255,255,0.05); }
|
||||||
|
.seg-row.active { background:rgba(96,165,250,0.18); }
|
||||||
|
.seg-time { color:#60a5fa; font-variant-numeric:tabular-nums; flex-shrink:0; font-size:0.82rem; padding-top:1px; }
|
||||||
|
.seg-text { color:#e2e8f0; }
|
||||||
|
</style>
|
||||||
|
|
||||||
<!-- 재작성 에디터 -->
|
<!-- 재작성 에디터 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@ -135,6 +167,99 @@
|
|||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 세그먼트(영상 싱크) =====
|
||||||
|
let SEGMENTS = [];
|
||||||
|
|
||||||
|
function mmss(sec){
|
||||||
|
sec = Math.max(0, Math.floor(sec||0));
|
||||||
|
const m = Math.floor(sec/60), s = sec%60;
|
||||||
|
return String(m).padStart(2,'0')+':'+String(s).padStart(2,'0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function localVideo(){
|
||||||
|
const v = document.getElementById('localVideo');
|
||||||
|
return (v && v.src) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSegments(segs){
|
||||||
|
SEGMENTS = Array.isArray(segs) ? segs : [];
|
||||||
|
const box = document.getElementById('segmentList');
|
||||||
|
const srtBar = document.getElementById('srtBar');
|
||||||
|
if(!SEGMENTS.length){
|
||||||
|
box.style.display = 'none'; box.innerHTML = '';
|
||||||
|
srtBar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = SEGMENTS.map((sg,i)=>
|
||||||
|
'<div class="seg-row" data-i="'+i+'" data-start="'+sg.start+'" onclick="seekTo('+sg.start+')">'
|
||||||
|
+ '<span class="seg-time">'+mmss(sg.start)+'</span>'
|
||||||
|
+ '<span class="seg-text">'+esc(sg.text)+'</span></div>').join('');
|
||||||
|
box.style.display = 'block';
|
||||||
|
srtBar.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekTo(sec){
|
||||||
|
const v = localVideo();
|
||||||
|
if(v){ v.currentTime = sec; v.play().catch(()=>{}); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightAt(t){
|
||||||
|
const rows = document.querySelectorAll('#segmentList .seg-row');
|
||||||
|
let activeIdx = -1;
|
||||||
|
for(let i=0;i<SEGMENTS.length;i++){
|
||||||
|
const s = SEGMENTS[i];
|
||||||
|
if(t >= s.start && t < (s.end!=null ? s.end : Infinity)) { activeIdx = i; break; }
|
||||||
|
}
|
||||||
|
rows.forEach(r=>{
|
||||||
|
const on = Number(r.dataset.i)===activeIdx;
|
||||||
|
r.classList.toggle('active', on);
|
||||||
|
if(on) r.scrollIntoView({block:'nearest'});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportSrt(){
|
||||||
|
const speed = parseFloat(document.getElementById('srtSpeed').value) || 1.0;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = API + '/' + VIDEO_ID + '/script.srt?speed=' + encodeURIComponent(speed);
|
||||||
|
a.download = 'script_' + VIDEO_ID + '.srt';
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onVideoSelected(ev){
|
||||||
|
const file = ev.target.files && ev.target.files[0];
|
||||||
|
if(!file) return;
|
||||||
|
|
||||||
|
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
|
||||||
|
const v = document.getElementById('localVideo');
|
||||||
|
v.src = URL.createObjectURL(file);
|
||||||
|
v.style.display = 'block';
|
||||||
|
document.getElementById('player').style.display = 'none';
|
||||||
|
|
||||||
|
// 2) 서버로 보내 Whisper 전사
|
||||||
|
const status = document.getElementById('transcribeStatus');
|
||||||
|
const btn = document.getElementById('uploadBtn');
|
||||||
|
status.style.display = 'block'; status.style.color = '#facc15';
|
||||||
|
status.textContent = '전사 중… (Shorts 기준 보통 20~60초)';
|
||||||
|
btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6';
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const s = await api(API + '/' + VIDEO_ID + '/transcribe', { method:'POST', body: fd });
|
||||||
|
renderSegments(s.segments);
|
||||||
|
document.getElementById('transcript').value = s.transcript || '';
|
||||||
|
status.style.color = '#4ade80';
|
||||||
|
status.textContent = '전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
|
||||||
|
+ (s.language ? (' · ' + s.language) : '')
|
||||||
|
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
|
||||||
|
} catch(e){
|
||||||
|
status.style.color = '#f87171';
|
||||||
|
status.textContent = '전사 실패: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
btn.style.pointerEvents = ''; btn.style.opacity = '';
|
||||||
|
if(window.lucide) lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load(){
|
async function load(){
|
||||||
let v;
|
let v;
|
||||||
try { v = await api(API + '/' + VIDEO_ID); }
|
try { v = await api(API + '/' + VIDEO_ID); }
|
||||||
@ -151,11 +276,17 @@
|
|||||||
'<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+v.videoId+'" frameborder="0" allow="encrypted-media" allowfullscreen></iframe>';
|
'<iframe width="100%" height="100%" src="https://www.youtube.com/embed/'+v.videoId+'" frameborder="0" allow="encrypted-media" allowfullscreen></iframe>';
|
||||||
document.getElementById('reworkText').value = v.reworkText || '';
|
document.getElementById('reworkText').value = v.reworkText || '';
|
||||||
|
|
||||||
// 스크립트
|
// 스크립트(평문 + 영상 싱크 세그먼트)
|
||||||
try {
|
try {
|
||||||
const s = await api(API + '/' + VIDEO_ID + '/script');
|
const s = await api(API + '/' + VIDEO_ID + '/script');
|
||||||
document.getElementById('transcript').value = s.hasScript ? s.transcript : '';
|
document.getElementById('transcript').value = s.hasScript ? s.transcript : '';
|
||||||
|
renderSegments(s.segments);
|
||||||
} catch(e){ /* ignore */ }
|
} catch(e){ /* ignore */ }
|
||||||
|
|
||||||
|
// 업로드 영상 재생에 맞춰 현재 세그먼트 하이라이트
|
||||||
|
const lv = document.getElementById('localVideo');
|
||||||
|
lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime));
|
||||||
|
|
||||||
if(window.lucide) lucide.createIcons();
|
if(window.lucide) lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.hlab.yanalyst.domain.channel;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SRT 생성기 단위 테스트 (DB/Spring 불필요).
|
||||||
|
*/
|
||||||
|
class SrtFormatterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSrt_formatsSegmentsWithSrtTimecodes() {
|
||||||
|
String srt = SrtFormatter.toSrt(List.of(
|
||||||
|
new ScriptSegment(0.0, 2.4, "안녕하세요"),
|
||||||
|
new ScriptSegment(2.4, 5.0, "반갑습니다")
|
||||||
|
), 1.0);
|
||||||
|
|
||||||
|
assertThat(srt).isEqualTo(
|
||||||
|
"1\n00:00:00,000 --> 00:00:02,400\n안녕하세요\n\n"
|
||||||
|
+ "2\n00:00:02,400 --> 00:00:05,000\n반갑습니다\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSrt_appliesSpeedByDividingTimestamps() {
|
||||||
|
// 2배속이면 시각이 절반으로 당겨진다.
|
||||||
|
String srt = SrtFormatter.toSrt(List.of(
|
||||||
|
new ScriptSegment(10.0, 20.0, "x")
|
||||||
|
), 2.0);
|
||||||
|
|
||||||
|
assertThat(srt).contains("00:00:05,000 --> 00:00:10,000");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSrt_formatsHoursAndMillisCorrectly() {
|
||||||
|
String srt = SrtFormatter.toSrt(List.of(
|
||||||
|
new ScriptSegment(3661.123, 3661.5, "t")
|
||||||
|
), 1.0);
|
||||||
|
|
||||||
|
assertThat(srt).contains("01:01:01,123 --> 01:01:01,500");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSrt_invalidSpeedFallsBackToOne() {
|
||||||
|
String a = SrtFormatter.toSrt(List.of(new ScriptSegment(1.0, 2.0, "t")), 0.0);
|
||||||
|
String b = SrtFormatter.toSrt(List.of(new ScriptSegment(1.0, 2.0, "t")), 1.0);
|
||||||
|
assertThat(a).isEqualTo(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSrt_emptyOrNullSegmentsYieldEmptyString() {
|
||||||
|
assertThat(SrtFormatter.toSrt(List.of(), 1.0)).isEmpty();
|
||||||
|
assertThat(SrtFormatter.toSrt(null, 1.0)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toSrt_clampsNegativeAndInvertedTimes() {
|
||||||
|
String srt = SrtFormatter.toSrt(List.of(
|
||||||
|
new ScriptSegment(-1.0, -0.5, "a"), // 음수 → 0 으로 클램프
|
||||||
|
new ScriptSegment(5.0, 3.0, "b") // end<start → end 를 start 로
|
||||||
|
), 1.0);
|
||||||
|
|
||||||
|
assertThat(srt).contains("00:00:00,000 --> 00:00:00,000\na");
|
||||||
|
assertThat(srt).contains("00:00:05,000 --> 00:00:05,000\nb");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user