diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index 8ee78d3..82fc9fc 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -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 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 body = new LinkedMultiValueMap<>(); + body.add("file", fileResource); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity 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 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 parseSegments(String segmentsJson) { + if (segmentsJson == null || segmentsJson.isBlank()) { + return Collections.emptyList(); + } + try { + List raw = objectMapper.readValue( + segmentsJson, + objectMapper.getTypeFactory().constructCollectionType(List.class, ScriptResponseDto.Segment.class)); + List 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 videos = channelVideoRepository.findByChannelId(channelId); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java index 65d6d29..9a8a6a6 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -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> 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> 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> 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 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 saveRework(@PathVariable Long id, @RequestBody Map body) { diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java index 1a942fe..45f2be6 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -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 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 segments = channelService.getSegments(v.getId()); + + Map 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 transcribeFromFile(Long videoId, MultipartFile file) { + ChannelVideo v = find(videoId); + ScriptResponseDto dto = channelService.transcribeFromFile(v.getId(), file); + + Map 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); } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoScript.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoScript.java index cf17887..70784f0 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoScript.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoScript.java @@ -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; diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoScriptRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoScriptRepository.java index 2665f48..e77aef6 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoScriptRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoScriptRepository.java @@ -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 { - Optional findByVideoId(String videoId); + /** 같은 videoId 에 중복 row 가 있어도 최신(id 큰) 1건만 안전하게 반환한다. */ + Optional findFirstByVideoIdOrderByIdDesc(String videoId); + + /** videoId 에 해당하는 모든 스크립트(중복 포함). 정리/삭제용. */ + List findAllByVideoId(String videoId); + boolean existsByVideoId(String videoId); -} +} \ No newline at end of file diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ScriptSegment.java b/src/main/java/com/hlab/yanalyst/domain/channel/ScriptSegment.java new file mode 100644 index 0000000..6701daf --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ScriptSegment.java @@ -0,0 +1,8 @@ +package com.hlab.yanalyst.domain.channel; + +/** + * 자막 한 구간(세그먼트). 영상과 싱크되는 시작/끝 시각(초)과 텍스트. + * Whisper 전사 결과 및 SRT 생성의 공통 단위. + */ +public record ScriptSegment(double start, double end, String text) { +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/SrtFormatter.java b/src/main/java/com/hlab/yanalyst/domain/channel/SrtFormatter.java new file mode 100644 index 0000000..f37016b --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/SrtFormatter.java @@ -0,0 +1,55 @@ +package com.hlab.yanalyst.domain.channel; + +import java.util.List; + +/** + * 세그먼트 목록을 SRT 자막 문자열로 변환하는 순수 유틸. + * CapCut 등 외부 편집기로 import 가능한 표준 SRT 를 생성한다. + * + *

{@code speed} 는 영상 배속을 의미한다. 예: 1.2배속이면 모든 타임스탬프를 + * 1.2 로 나눠 자막이 빨라진 영상과 싱크되도록 보정한다. + */ +public final class SrtFormatter { + + private SrtFormatter() { + } + + public static String toSrt(List 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); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/production/dto/ScriptResponseDto.java b/src/main/java/com/hlab/yanalyst/domain/production/dto/ScriptResponseDto.java index 7d85f96..26503a0 100644 --- a/src/main/java/com/hlab/yanalyst/domain/production/dto/ScriptResponseDto.java +++ b/src/main/java/com/hlab/yanalyst/domain/production/dto/ScriptResponseDto.java @@ -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 segments; + + @Data + public static class Segment { + private double start; + private double end; + private String text; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4cd31db..96d062d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index bab7abc..3d7af73 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -20,6 +20,8 @@

+
@@ -32,21 +34,51 @@
-
+

원본 스크립트

-
-
- + + + + + + + + + + +
+ +
@@ -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)=> + '
' + + ''+mmss(sg.start)+'' + + ''+esc(sg.text)+'
').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= 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) 업로드한 파일을 로컬에서 즉시 재생(