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.ObjectMapper;
|
||||
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.transaction.annotation.Transactional;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@ -31,6 +43,9 @@ public class ChannelService {
|
||||
@Value("${youtube.api.key}") // application.yml(youtube.api.key) → 환경변수 YOUTUBE_API_KEY 오버라이드
|
||||
private String youtubeApiKey;
|
||||
|
||||
@Value("${python.base-url:http://h-python.tolag.shop}") // Python 마이크로서비스(자막/전사) 베이스 URL
|
||||
private String pythonBaseUrl;
|
||||
|
||||
@Transactional
|
||||
public Channel saveChannelFromUrl(String url) {
|
||||
String identifier = extractIdentifier(url);
|
||||
@ -182,8 +197,8 @@ public class ChannelService {
|
||||
public void deleteChannel(Long id) {
|
||||
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(id);
|
||||
for (ChannelVideo video : videos) {
|
||||
channelVideoScriptRepository.findByVideoId(video.getVideoId())
|
||||
.ifPresent(channelVideoScriptRepository::delete);
|
||||
channelVideoScriptRepository.deleteAll(
|
||||
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||
channelVideoRepository.delete(video);
|
||||
}
|
||||
channelRepository.deleteById(id);
|
||||
@ -327,7 +342,7 @@ public class ChannelService {
|
||||
ChannelVideo video = channelVideoRepository.findById(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
|
||||
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 =
|
||||
objectMapper.readValue(response.getBody(), com.hlab.yanalyst.domain.production.dto.ScriptResponseDto.class);
|
||||
|
||||
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
||||
channelVideoScriptRepository.deleteAll(
|
||||
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||
|
||||
ChannelVideoScript script = new ChannelVideoScript();
|
||||
script.setChannelVideoId(channelVideoId);
|
||||
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
|
||||
public void extractAllScripts(Long 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.tags.Tag;
|
||||
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.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -115,19 +120,43 @@ public class ChannelVideoCurationController {
|
||||
}
|
||||
|
||||
@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) {
|
||||
String t = curationService.getTranscript(id);
|
||||
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
|
||||
return ApiResponse.ok(curationService.getScriptData(id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/extract-script")
|
||||
@Operation(summary = "원본 스크립트 추출", description = "외부 transcript 서비스로 자막을 추출해 저장한다.")
|
||||
@Operation(summary = "원본 스크립트 추출(URL 자막)", description = "외부 transcript 서비스로 YouTube 자막을 추출해 저장한다.")
|
||||
public ApiResponse<Map<String, Object>> extractScript(@PathVariable Long id) {
|
||||
String t = curationService.extractTranscript(id);
|
||||
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")
|
||||
@Operation(summary = "재작성 초안 저장", description = "body: {\"reworkText\": \"...\"} — 저장 시 상태가 TARGET 으로 승격된다.")
|
||||
public ApiResponse<ChannelVideo> saveRework(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@ -69,7 +73,7 @@ public class ChannelVideoCurationService {
|
||||
/** 원본 스크립트(transcript) 조회. 없으면 null. */
|
||||
public String getTranscript(Long videoId) {
|
||||
ChannelVideo v = find(videoId);
|
||||
return channelVideoScriptRepository.findByVideoId(v.getVideoId())
|
||||
return channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId())
|
||||
.map(ChannelVideoScript::getTranscript)
|
||||
.orElse(null);
|
||||
}
|
||||
@ -79,11 +83,51 @@ public class ChannelVideoCurationService {
|
||||
public String extractTranscript(Long videoId) {
|
||||
ChannelVideo v = find(videoId);
|
||||
channelService.extractScript(v.getId()); // channel_video_scripts 에 저장 + hasScript=true
|
||||
return channelVideoScriptRepository.findByVideoId(v.getVideoId())
|
||||
return channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId())
|
||||
.map(ChannelVideoScript::getTranscript)
|
||||
.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 이면). */
|
||||
@Transactional
|
||||
public ChannelVideo saveRework(Long videoId, String text) {
|
||||
@ -99,8 +143,8 @@ public class ChannelVideoCurationService {
|
||||
@Transactional
|
||||
public void delete(Long videoId) {
|
||||
ChannelVideo video = find(videoId);
|
||||
channelVideoScriptRepository.findByVideoId(video.getVideoId())
|
||||
.ifPresent(channelVideoScriptRepository::delete);
|
||||
channelVideoScriptRepository.deleteAll(
|
||||
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||
channelVideoRepository.delete(video);
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,10 @@ public class ChannelVideoScript {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String transcript;
|
||||
|
||||
/** 영상 싱크 자막 세그먼트 JSON 배열([{start,end,text},...]). Whisper 전사 시 채워진다. */
|
||||
@Column(name = "segments_json", columnDefinition = "TEXT")
|
||||
private String segmentsJson;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ -2,9 +2,15 @@ package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -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,11 +3,26 @@ package com.hlab.yanalyst.domain.production.dto;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class ScriptResponseDto {
|
||||
@JsonProperty("video_id")
|
||||
private String videoId;
|
||||
|
||||
|
||||
private String language;
|
||||
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.
|
||||
config:
|
||||
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:
|
||||
url: ${DB_URL:}
|
||||
driverClassName: org.postgresql.Driver
|
||||
@ -46,6 +50,10 @@ youtube:
|
||||
api:
|
||||
key: ${YOUTUBE_API_KEY:}
|
||||
|
||||
# Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드.
|
||||
python:
|
||||
base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop}
|
||||
|
||||
hlab:
|
||||
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
||||
scheduler:
|
||||
|
||||
@ -20,6 +20,8 @@
|
||||
<!-- 좌: 영상 + 정보 -->
|
||||
<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>
|
||||
<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="font-bold" id="vTitle" style="line-height:1.4;"></div>
|
||||
<div class="text-muted" id="vChannel"></div>
|
||||
@ -32,21 +34,51 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- 원본 스크립트 -->
|
||||
<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>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="extractBtn" onclick="extractScript()">
|
||||
<i data-lucide="download-cloud" style="width:15px;"></i> 추출
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||
<label class="btn btn-primary px-3 py-2 flex items-center gap-1" id="uploadBtn" style="cursor:pointer;">
|
||||
<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 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> 에디터로 복사
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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="flex items-center justify-between mb-3">
|
||||
@ -135,6 +167,99 @@
|
||||
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(){
|
||||
let v;
|
||||
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>';
|
||||
document.getElementById('reworkText').value = v.reworkText || '';
|
||||
|
||||
// 스크립트
|
||||
// 스크립트(평문 + 영상 싱크 세그먼트)
|
||||
try {
|
||||
const s = await api(API + '/' + VIDEO_ID + '/script');
|
||||
document.getElementById('transcript').value = s.hasScript ? s.transcript : '';
|
||||
renderSegments(s.segments);
|
||||
} catch(e){ /* ignore */ }
|
||||
|
||||
// 업로드 영상 재생에 맞춰 현재 세그먼트 하이라이트
|
||||
const lv = document.getElementById('localVideo');
|
||||
lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime));
|
||||
|
||||
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