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:
hehihoho3@gmail.com 2026-06-12 16:23:55 +09:00
parent fa7cec7f14
commit b9aa04d4a3
11 changed files with 501 additions and 21 deletions

View File

@ -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);

View File

@ -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) {

View File

@ -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);
} }

View File

@ -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;

View File

@ -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);
} }

View File

@ -0,0 +1,8 @@
package com.hlab.yanalyst.domain.channel;
/**
* 자막 구간(세그먼트). 영상과 싱크되는 시작/ 시각() 텍스트.
* Whisper 전사 결과 SRT 생성의 공통 단위.
*/
public record ScriptSegment(double start, double end, String text) {
}

View File

@ -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);
}
}

View File

@ -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;
}
} }

View File

@ -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:

View File

@ -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();
} }

View File

@ -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");
}
}