Compare commits
No commits in common. "de30174cd1860a1cb30bd1049d69dcc4f21278d0" and "42ec17b0546cc7443bc12694f1a1601974b023a3" have entirely different histories.
de30174cd1
...
42ec17b054
@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
name: shortform-queue
|
|
||||||
description: h-lab 숏폼 큐의 대기 작업을 구글 Opal "숏폼 생성기"로 실행하고 결과를 DB에 저장. "숏폼 큐 돌려줘", "/shortform-queue", "숏폼 돌려줘" 요청 시 사용.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 숏폼 큐 실행 (Opal 브라우저 대행)
|
|
||||||
|
|
||||||
h-lab(localhost:8088)의 PENDING 작업을 Opal로 실행해 결과를 저장한다.
|
|
||||||
h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5)으로 띄운다.
|
|
||||||
|
|
||||||
## 절차
|
|
||||||
|
|
||||||
1. **큐 조회**: `GET http://localhost:8088/api/shortform/jobs?status=PENDING` → 비어있으면 "대기 작업 없음" 보고 후 종료.
|
|
||||||
2. **Opal 접속**: 크롬 자동화로 `https://opal.google/edit/1w3mThvSpI3_skt4p4OAN3-M6-tnqz0HN` (숏폼 생성기 편집 화면). 로그인 풀려 있으면 사용자에게 로그인 요청.
|
|
||||||
3. 각 PENDING 작업마다:
|
|
||||||
a. 캔버스의 **YouTube Video 노드 클릭** → 우측 패널 URL 입력창을 비우고 작업의 youtubeUrl 입력 → **Apply**.
|
|
||||||
b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭.
|
|
||||||
c. **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬).
|
|
||||||
d. **Output 추출**: 앱 UI는 접근성 트리에 안 잡힌다. javascript_tool로 `document.getElementById('opal-app').contentDocument`에서 shadow DOM을 재귀 관통해 `"cuts"`를 포함한 **가장 긴** innerText를 찾는다. 텍스트가 크면 window 변수에 저장 후 900자 단위로 나눠 회수한다.
|
|
||||||
e. **저장**: `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<추출 원문>"}`. 응답의 clips가 5개 미만이면 원문과 함께 보고.
|
|
||||||
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
|
|
||||||
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.
|
|
||||||
|
|
||||||
## 주의
|
|
||||||
|
|
||||||
- Opal은 무료 실험 서비스 — 일일 사용량 제한이 있으니 큐가 많으면 사용자에게 몇 개까지 돌릴지 확인.
|
|
||||||
- Opal 앱 구조(노드 구성·프롬프트)가 바뀌어 보이면 조작을 멈추고 사용자에게 보고.
|
|
||||||
- 쿠키 배너가 뜨면 "나중에"(거절)를 선택.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,62 +0,0 @@
|
|||||||
# 숏폼 큐 — Opal 연동 작업 큐/결과 저장 설계
|
|
||||||
|
|
||||||
2026-08-03. 유튜브 URL을 h-lab에 등록하면, 구글 Opal "숏폼 생성기" 앱(무료, Gemini Pro 영상분석)을 실행 엔진으로 삼아 결과를 DB에 구조화 저장하고 조회하는 기능.
|
|
||||||
|
|
||||||
## 배경·제약
|
|
||||||
|
|
||||||
- Opal은 외부 API가 없다. 실행은 (A) 클로드가 브라우저 자동화로 대행하거나 (B) 사용자가 수동 실행 후 결과를 붙여넣는다. 두 경로 모두 동일한 저장 API로 수렴한다.
|
|
||||||
- Gemini API 직접 호출은 Pro가 유료(2026-04부터 무료 티어 제외)라 배제. 단, 실행 엔진 교체가 쉽도록 h-lab은 "결과 텍스트를 받아 파싱·저장"만 담당한다.
|
|
||||||
- Opal 출력 형식(프롬프트 v13.x): 구간(ID 1~5)별로 `N=====` 구분자 + ```json 블록(capcut2 붙여넣기용) + 📌 타이틀 후보 5선 + ⏱️ 컷 길이 검산표.
|
|
||||||
|
|
||||||
## 구성요소
|
|
||||||
|
|
||||||
### 1. 백엔드 — `domain/shortform/` (DDD 컨벤션)
|
|
||||||
|
|
||||||
**엔티티**
|
|
||||||
- `ShortformJob` (테이블 `shortform_job`): id, youtubeUrl, videoId(URL에서 추출), title(nullable, 결과 JSON의 title_main 첫 값 또는 null), status(PENDING/DONE/FAILED), rawOutput(TEXT, 파싱 실패 대비 원문 보존), createdAt, completedAt
|
|
||||||
- `ShortformClip` (테이블 `shortform_clip`): id, job FK, clipNo(1~5), capcutJson(TEXT), titleCandidates(TEXT), durationTable(TEXT), titleTop/titleMain(파싱값, nullable)
|
|
||||||
|
|
||||||
**API** (`ApiResponse<T>` 래핑, `/api/shortform/...`, web/ 스타일 경로)
|
|
||||||
- `POST /api/shortform/jobs` {youtubeUrl} → 큐 등록(PENDING). 중복 URL 등록 시 기존 작업 반환
|
|
||||||
- `GET /api/shortform/jobs` → 목록(최신순)
|
|
||||||
- `GET /api/shortform/jobs/{id}` → 상세(클립 포함)
|
|
||||||
- `POST /api/shortform/jobs/{id}/result` {rawText} → 파싱·저장, status 갱신
|
|
||||||
- `POST /api/shortform/import` {youtubeUrl, rawText} → 등록+결과 저장 한 번에 (수동 실행 후 붙여넣기용)
|
|
||||||
- `DELETE /api/shortform/jobs/{id}`
|
|
||||||
|
|
||||||
**파서** (`ShortformOutputParser`)
|
|
||||||
- `N=====` 형태 구분자로 섹션 분리 → 섹션마다 ```json 코드블록, "타이틀 후보" 블록, "검산표" 블록 추출
|
|
||||||
- JSON 파싱 성공 시 titleTop/titleMain 추출. 어떤 단계든 실패하면 해당 클립은 원문 조각만 저장하고 job은 DONE 유지(rawOutput이 항상 원본 보존) — 파싱 실패로 데이터를 잃지 않는다
|
|
||||||
- 클립이 0개 추출되면 FAILED + rawOutput 보존
|
|
||||||
|
|
||||||
### 2. 프론트 — `/shortform` 페이지 (Thymeleaf, layout/base)
|
|
||||||
|
|
||||||
- 사이드바에 "숏폼" 메뉴 추가 (`currentPage=shortform`)
|
|
||||||
- 상단: URL 입력 폼(등록) + "결과 붙여넣기" 토글(URL+원문 textarea → import API)
|
|
||||||
- 목록: 작업 카드(썸네일 `img.youtube.com/vi/{videoId}/mqdefault.jpg`, 상태 뱃지, 등록시각)
|
|
||||||
- 상세(카드 펼침 또는 별도 영역): 클립 1~5 카드 — 타이틀(top/main), 타이틀 후보 5선, 검산표, **capcut2 JSON 복사 버튼**(클립보드 API)
|
|
||||||
|
|
||||||
### 3. 스킬 — `.claude/skills/shortform-queue/SKILL.md`
|
|
||||||
|
|
||||||
트리거: `/shortform-queue`, "숏폼 큐 돌려줘" 류 요청.
|
|
||||||
절차(오늘 세션에서 검증된 노하우를 명문화):
|
|
||||||
1. `GET /api/shortform/jobs`에서 PENDING 목록 조회 (h-lab은 localhost:8088)
|
|
||||||
2. 크롬 자동화로 opal.google → 숏폼 생성기 앱(edit URL 고정) 열기
|
|
||||||
3. YouTube Video 노드 클릭 → URL 입력 → Apply → Preview의 Start
|
|
||||||
4. 완료 폴링(전체 4~5분, Step 3 각 ~90초). 앱 UI는 접근성 트리에 안 잡히므로 스크린샷 + `iframe#opal-app` contentDocument에 JS 주입(shadow DOM 관통)으로 상태 확인
|
|
||||||
5. Output 전문 추출(JS, `"cuts"` 포함 최대 텍스트) → `POST /api/shortform/jobs/{id}/result`
|
|
||||||
6. 노드 에러 시 1회 재실행, 재실패면 FAILED 보고 후 다음 작업 진행. 큐 소진까지 반복, 요약 보고
|
|
||||||
주의사항: Opal 프롬프트 끝 중복 칩 이슈(2026-08-03 해결) 같은 앱 구조 변경 감지 시 사용자에게 보고.
|
|
||||||
|
|
||||||
## 순서·범위
|
|
||||||
|
|
||||||
1. 백엔드(엔티티→파서→API) — 파서는 오늘 실행에서 얻은 실제 Output 샘플로 단위 테스트
|
|
||||||
2. 프론트 페이지
|
|
||||||
3. 스킬 파일
|
|
||||||
빌드·커밋은 변경 단위별. 실제 Opal 실행 검증은 스킬 완성 후 사용자 요청 시.
|
|
||||||
|
|
||||||
## 하지 않는 것 (YAGNI)
|
|
||||||
|
|
||||||
- Gemini API 직접 호출(비용) — 파서/저장 구조만 호환되게 유지
|
|
||||||
- n8n 연동, 스케줄 자동 실행(Opal이 로그인 세션 필요, 무인 실행 불가)
|
|
||||||
- capcut2 앱으로의 직접 전송(현행 수동 붙여넣기 유지)
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
/** Opal 출력에서 파싱한 구간 하나. capcutJson 등 원문 조각은 파싱 실패해도 보존한다. */
|
|
||||||
public record ParsedClip(
|
|
||||||
int clipNo,
|
|
||||||
String capcutJson,
|
|
||||||
String titleCandidates,
|
|
||||||
String durationTable,
|
|
||||||
String titleTop,
|
|
||||||
String titleMain
|
|
||||||
) {}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
/** 작업 하나에서 나온 구간(ID 1~5) 하나. capcutJson이 실제 편집 프로그램에 붙여넣는 값. */
|
|
||||||
@Entity
|
|
||||||
@Table(name = "shortform_clip")
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class ShortformClip {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
|
||||||
@JoinColumn(name = "job_id")
|
|
||||||
private ShortformJob job;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private Integer clipNo;
|
|
||||||
|
|
||||||
@Column(length = 100)
|
|
||||||
private String titleTop;
|
|
||||||
|
|
||||||
@Column(length = 200)
|
|
||||||
private String titleMain;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String capcutJson;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String titleCandidates;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String durationTable;
|
|
||||||
|
|
||||||
static ShortformClip of(ShortformJob job, ParsedClip p) {
|
|
||||||
ShortformClip clip = new ShortformClip();
|
|
||||||
clip.job = job;
|
|
||||||
clip.clipNo = p.clipNo();
|
|
||||||
clip.titleTop = p.titleTop();
|
|
||||||
clip.titleMain = p.titleMain();
|
|
||||||
clip.capcutJson = p.capcutJson();
|
|
||||||
clip.titleCandidates = p.titleCandidates();
|
|
||||||
clip.durationTable = p.durationTable();
|
|
||||||
return clip;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.ImportRequest;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.RegisterRequest;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.ResultRequest;
|
|
||||||
import com.hlab.yanalyst.global.common.ApiResponse;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/shortform")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ShortformController {
|
|
||||||
|
|
||||||
private final ShortformService shortformService;
|
|
||||||
|
|
||||||
@PostMapping("/jobs")
|
|
||||||
public ApiResponse<JobDetail> register(@RequestBody RegisterRequest request) {
|
|
||||||
return ApiResponse.created(shortformService.register(request.youtubeUrl()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/jobs")
|
|
||||||
public ApiResponse<List<JobSummary>> list(@RequestParam(required = false) ShortformJobStatus status) {
|
|
||||||
return ApiResponse.ok(shortformService.list(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/jobs/{id}")
|
|
||||||
public ApiResponse<JobDetail> detail(@PathVariable Long id) {
|
|
||||||
return ApiResponse.ok(shortformService.detail(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/jobs/{id}/result")
|
|
||||||
public ApiResponse<JobDetail> saveResult(@PathVariable Long id, @RequestBody ResultRequest request) {
|
|
||||||
return ApiResponse.ok(shortformService.saveResult(id, request.rawText()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/import")
|
|
||||||
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) {
|
|
||||||
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/jobs/{id}")
|
|
||||||
public ApiResponse<Long> delete(@PathVariable Long id) {
|
|
||||||
shortformService.delete(id);
|
|
||||||
return ApiResponse.ok(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 숏폼 큐 작업 하나 = 유튜브 영상 하나. Opal "숏폼 생성기" 실행 결과를 담는다.
|
|
||||||
*
|
|
||||||
* <p>rawOutput은 파싱 성공 여부와 무관하게 항상 원문을 보존한다 — 파서가 놓친 정보는
|
|
||||||
* 여기서 복구할 수 있다.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "shortform_job",
|
|
||||||
indexes = @Index(name = "idx_sfj_video_id", columnList = "videoId", unique = true))
|
|
||||||
@Getter
|
|
||||||
@NoArgsConstructor
|
|
||||||
public class ShortformJob {
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 500)
|
|
||||||
private String youtubeUrl;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 20)
|
|
||||||
private String videoId;
|
|
||||||
|
|
||||||
/** 결과의 첫 클립 title_main. 목록 표시에 쓴다. */
|
|
||||||
@Column(length = 300)
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false, length = 20)
|
|
||||||
private ShortformJobStatus status = ShortformJobStatus.PENDING;
|
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
|
||||||
private String rawOutput;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
private LocalDateTime completedAt;
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "job", cascade = CascadeType.ALL, orphanRemoval = true)
|
|
||||||
@OrderBy("clipNo ASC")
|
|
||||||
private List<ShortformClip> clips = new ArrayList<>();
|
|
||||||
|
|
||||||
public static ShortformJob create(String youtubeUrl, String videoId) {
|
|
||||||
ShortformJob job = new ShortformJob();
|
|
||||||
job.youtubeUrl = youtubeUrl;
|
|
||||||
job.videoId = videoId;
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void applyResult(String raw, List<ParsedClip> parsed) {
|
|
||||||
this.rawOutput = raw;
|
|
||||||
this.clips.clear();
|
|
||||||
for (ParsedClip p : parsed) {
|
|
||||||
this.clips.add(ShortformClip.of(this, p));
|
|
||||||
}
|
|
||||||
this.title = parsed.isEmpty() ? this.title : parsed.get(0).titleMain();
|
|
||||||
this.status = ShortformJobStatus.DONE;
|
|
||||||
this.completedAt = LocalDateTime.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void markFailed(String raw) {
|
|
||||||
this.rawOutput = raw;
|
|
||||||
this.status = ShortformJobStatus.FAILED;
|
|
||||||
this.completedAt = LocalDateTime.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public interface ShortformJobRepository extends JpaRepository<ShortformJob, Long> {
|
|
||||||
Optional<ShortformJob> findByVideoId(String videoId);
|
|
||||||
List<ShortformJob> findAllByOrderByCreatedAtDesc();
|
|
||||||
List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status);
|
|
||||||
}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
public enum ShortformJobStatus { PENDING, DONE, FAILED }
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opal "숏폼 생성기" Output 원문을 구간(ID N)별로 파싱한다.
|
|
||||||
*
|
|
||||||
* <p>실전 출력 변형에 대응한다: 섹션 구분자 {@code N=====}(= 5개 이상), ```json 펜스가
|
|
||||||
* 있기도 없기도 함, 타이틀 후보 목록의 번호 유무 혼재. 어떤 조각이 깨져도 원문은
|
|
||||||
* ShortformJob.rawOutput에 보존되므로 여기서는 최선 추출만 한다.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class ShortformOutputParser {
|
|
||||||
|
|
||||||
private static final Pattern SECTION = Pattern.compile("(?m)^\\s*(\\d+)={5,}");
|
|
||||||
private static final ObjectMapper OM = new ObjectMapper();
|
|
||||||
|
|
||||||
public List<ParsedClip> parse(String raw) {
|
|
||||||
List<ParsedClip> result = new ArrayList<>();
|
|
||||||
if (raw == null || raw.isBlank()) return result;
|
|
||||||
|
|
||||||
Matcher m = SECTION.matcher(raw);
|
|
||||||
List<Integer> starts = new ArrayList<>();
|
|
||||||
List<Integer> ends = new ArrayList<>();
|
|
||||||
List<Integer> nos = new ArrayList<>();
|
|
||||||
while (m.find()) {
|
|
||||||
starts.add(m.end());
|
|
||||||
ends.add(m.start());
|
|
||||||
nos.add(Integer.parseInt(m.group(1)));
|
|
||||||
}
|
|
||||||
for (int i = 0; i < starts.size(); i++) {
|
|
||||||
int to = (i + 1 < starts.size()) ? ends.get(i + 1) : raw.length();
|
|
||||||
ParsedClip clip = parseSection(nos.get(i), raw.substring(starts.get(i), to));
|
|
||||||
if (clip != null) result.add(clip);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ParsedClip parseSection(int no, String body) {
|
|
||||||
String json = extractJson(body);
|
|
||||||
String titles = extractBetween(body, "타이틀 후보", "검산표");
|
|
||||||
String table = extractFromLine(body, "검산표");
|
|
||||||
if (json == null && titles == null && table == null) return null;
|
|
||||||
|
|
||||||
String top = null;
|
|
||||||
String main = null;
|
|
||||||
if (json != null) {
|
|
||||||
try {
|
|
||||||
JsonNode node = OM.readTree(json);
|
|
||||||
top = node.path("title_top").asText(null);
|
|
||||||
main = node.path("title_main").asText(null);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// JSON이 깨져도 원문 조각은 보존한다
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new ParsedClip(no, json, titles, table, top, main);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ```json 펜스가 있으면 그 내부, 없으면 첫 '{'부터 중괄호 균형이 맞는 지점까지. */
|
|
||||||
private String extractJson(String body) {
|
|
||||||
int fence = body.indexOf("```json");
|
|
||||||
String scope = body;
|
|
||||||
if (fence >= 0) {
|
|
||||||
int from = fence + "```json".length();
|
|
||||||
int close = body.indexOf("```", from);
|
|
||||||
scope = close > from ? body.substring(from, close) : body.substring(from);
|
|
||||||
}
|
|
||||||
int open = scope.indexOf('{');
|
|
||||||
if (open < 0) return null;
|
|
||||||
int depth = 0;
|
|
||||||
boolean inString = false;
|
|
||||||
for (int i = open; i < scope.length(); i++) {
|
|
||||||
char c = scope.charAt(i);
|
|
||||||
if (inString) {
|
|
||||||
if (c == '\\') i++;
|
|
||||||
else if (c == '"') inString = false;
|
|
||||||
} else if (c == '"') {
|
|
||||||
inString = true;
|
|
||||||
} else if (c == '{') {
|
|
||||||
depth++;
|
|
||||||
} else if (c == '}') {
|
|
||||||
depth--;
|
|
||||||
if (depth == 0) return scope.substring(open, i + 1).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** startKey가 있는 줄부터 endKey가 있는 줄 직전까지. 없으면 null. */
|
|
||||||
private String extractBetween(String body, String startKey, String endKey) {
|
|
||||||
int s = body.indexOf(startKey);
|
|
||||||
if (s < 0) return null;
|
|
||||||
int lineStart = body.lastIndexOf('\n', s) + 1;
|
|
||||||
int e = body.indexOf(endKey, s);
|
|
||||||
int end = e < 0 ? body.length() : body.lastIndexOf('\n', e) + 1;
|
|
||||||
String block = body.substring(lineStart, end).trim();
|
|
||||||
return block.isEmpty() ? null : block;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractFromLine(String body, String key) {
|
|
||||||
int s = body.indexOf(key);
|
|
||||||
if (s < 0) return null;
|
|
||||||
int lineStart = body.lastIndexOf('\n', s) + 1;
|
|
||||||
String block = body.substring(lineStart).trim();
|
|
||||||
return block.isEmpty() ? null : block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ShortformService {
|
|
||||||
|
|
||||||
private final ShortformJobRepository jobRepository;
|
|
||||||
private final ShortformOutputParser parser;
|
|
||||||
|
|
||||||
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
|
|
||||||
@Transactional
|
|
||||||
public JobDetail register(String youtubeUrl) {
|
|
||||||
String videoId = YoutubeVideoId.from(youtubeUrl)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("유튜브 URL이 아닙니다: " + youtubeUrl));
|
|
||||||
ShortformJob job = jobRepository.findByVideoId(videoId)
|
|
||||||
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId)));
|
|
||||||
return JobDetail.from(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */
|
|
||||||
@Transactional
|
|
||||||
public JobDetail saveResult(Long jobId, String rawText) {
|
|
||||||
ShortformJob job = jobRepository.findById(jobId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
||||||
List<ParsedClip> parsed = parser.parse(rawText);
|
|
||||||
if (parsed.isEmpty()) {
|
|
||||||
job.markFailed(rawText);
|
|
||||||
} else {
|
|
||||||
job.applyResult(rawText, parsed);
|
|
||||||
}
|
|
||||||
return JobDetail.from(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */
|
|
||||||
@Transactional
|
|
||||||
public JobDetail importResult(String youtubeUrl, String rawText) {
|
|
||||||
JobDetail registered = register(youtubeUrl);
|
|
||||||
return saveResult(registered.id(), rawText);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<JobSummary> list(ShortformJobStatus status) {
|
|
||||||
List<ShortformJob> jobs = (status == null)
|
|
||||||
? jobRepository.findAllByOrderByCreatedAtDesc()
|
|
||||||
: jobRepository.findByStatusOrderByCreatedAtAsc(status);
|
|
||||||
return jobs.stream().map(JobSummary::from).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public JobDetail detail(Long jobId) {
|
|
||||||
return jobRepository.findById(jobId).map(JobDetail::from)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(Long jobId) {
|
|
||||||
jobRepository.deleteById(jobId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/** 유튜브 URL에서 11자 videoId를 뽑는다. youtube.com / youtu.be 도메인만 인정. */
|
|
||||||
public final class YoutubeVideoId {
|
|
||||||
|
|
||||||
private static final Pattern PATTERN = Pattern.compile(
|
|
||||||
"^https?://(?:www\\.|m\\.)?(?:youtube\\.com/(?:watch\\?[^#]*\\bv=|shorts/|embed/|live/)|youtu\\.be/)"
|
|
||||||
+ "([A-Za-z0-9_-]{11})(?:[?&#/].*)?$");
|
|
||||||
|
|
||||||
private YoutubeVideoId() {}
|
|
||||||
|
|
||||||
public static Optional<String> from(String url) {
|
|
||||||
if (url == null || url.isBlank()) return Optional.empty();
|
|
||||||
Matcher m = PATTERN.matcher(url.trim());
|
|
||||||
return m.matches() ? Optional.of(m.group(1)) : Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform.dto;
|
|
||||||
|
|
||||||
import com.hlab.yanalyst.domain.shortform.ShortformClip;
|
|
||||||
import com.hlab.yanalyst.domain.shortform.ShortformJob;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class ShortformDtos {
|
|
||||||
|
|
||||||
private ShortformDtos() {}
|
|
||||||
|
|
||||||
public record RegisterRequest(String youtubeUrl) {}
|
|
||||||
|
|
||||||
public record ResultRequest(String rawText) {}
|
|
||||||
|
|
||||||
public record ImportRequest(String youtubeUrl, String rawText) {}
|
|
||||||
|
|
||||||
public record JobSummary(Long id, String youtubeUrl, String videoId, String title,
|
|
||||||
String status, int clipCount,
|
|
||||||
LocalDateTime createdAt, LocalDateTime completedAt) {
|
|
||||||
public static JobSummary from(ShortformJob job) {
|
|
||||||
return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
|
|
||||||
job.getTitle(), job.getStatus().name(), job.getClips().size(),
|
|
||||||
job.getCreatedAt(), job.getCompletedAt());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record ClipDto(int clipNo, String titleTop, String titleMain,
|
|
||||||
String capcutJson, String titleCandidates, String durationTable) {
|
|
||||||
public static ClipDto from(ShortformClip clip) {
|
|
||||||
return new ClipDto(clip.getClipNo(), clip.getTitleTop(), clip.getTitleMain(),
|
|
||||||
clip.getCapcutJson(), clip.getTitleCandidates(), clip.getDurationTable());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record JobDetail(Long id, String youtubeUrl, String videoId, String title,
|
|
||||||
String status, LocalDateTime createdAt, LocalDateTime completedAt,
|
|
||||||
List<ClipDto> clips) {
|
|
||||||
public static JobDetail from(ShortformJob job) {
|
|
||||||
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
|
|
||||||
job.getTitle(), job.getStatus().name(), job.getCreatedAt(),
|
|
||||||
job.getCompletedAt(), job.getClips().stream().map(ClipDto::from).toList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -64,12 +64,6 @@ public class WebController {
|
|||||||
return "feed";
|
return "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/shortform")
|
|
||||||
public String shortform(Model model) {
|
|
||||||
model.addAttribute("currentPage", "shortform");
|
|
||||||
return "shortform";
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/recommend")
|
@GetMapping("/recommend")
|
||||||
public String recommend(Model model) {
|
public String recommend(Model model) {
|
||||||
model.addAttribute("currentPage", "recommend");
|
model.addAttribute("currentPage", "recommend");
|
||||||
|
|||||||
@ -24,9 +24,6 @@
|
|||||||
<a th:href="@{/feed}" class="nav-item" th:classappend="${currentPage == 'feed'} ? 'active'">
|
<a th:href="@{/feed}" class="nav-item" th:classappend="${currentPage == 'feed'} ? 'active'">
|
||||||
<i data-lucide="rss" class="nav-icon"></i><span class="nav-text">소재 피드</span>
|
<i data-lucide="rss" class="nav-icon"></i><span class="nav-text">소재 피드</span>
|
||||||
</a>
|
</a>
|
||||||
<a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'">
|
|
||||||
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="nav-section">분석</div>
|
<div class="nav-section">분석</div>
|
||||||
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
|
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
|
||||||
|
|||||||
@ -1,166 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
|
||||||
layout:decorate="~{layout/base}">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<title>h-lab - 숏폼 큐</title>
|
|
||||||
<style>
|
|
||||||
.sf-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
|
||||||
.sf-form input { flex: 1; }
|
|
||||||
.sf-paste { display: none; margin-bottom: 1rem; }
|
|
||||||
.sf-paste.open { display: block; }
|
|
||||||
.sf-paste textarea { width: 100%; min-height: 160px; font-family: monospace; font-size: 0.8rem; }
|
|
||||||
.sf-job { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 0.75rem; overflow: hidden; }
|
|
||||||
.sf-job-head { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; cursor: pointer; }
|
|
||||||
.sf-job-head img { width: 96px; border-radius: 6px; flex-shrink: 0; }
|
|
||||||
.sf-job-title { flex: 1; min-width: 0; }
|
|
||||||
.sf-job-title .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.sf-job-title .u { font-size: 0.75rem; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.sf-badge { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 99px; font-weight: 600; flex-shrink: 0; }
|
|
||||||
.sf-badge.PENDING { background: rgba(196, 122, 18, 0.15); color: var(--warning); }
|
|
||||||
.sf-badge.DONE { background: rgba(var(--success-rgb), 0.15); color: var(--success); }
|
|
||||||
.sf-badge.FAILED { background: rgba(var(--danger-rgb), 0.15); color: var(--danger); }
|
|
||||||
.sf-clips { display: none; padding: 0.75rem 0.9rem; border-top: 1px solid var(--border); }
|
|
||||||
.sf-job.open .sf-clips { display: block; }
|
|
||||||
.sf-clip { border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 0.6rem; }
|
|
||||||
.sf-clip-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; flex-wrap: wrap; }
|
|
||||||
.sf-clip-head .no { font-weight: 700; }
|
|
||||||
.sf-clip pre { font-size: 0.72rem; white-space: pre-wrap; background: rgba(127, 127, 127, 0.08); border-radius: 6px; padding: 0.5rem; margin: 0.35rem 0; max-height: 180px; overflow-y: auto; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div layout:fragment="content">
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<h1>숏폼 큐</h1>
|
|
||||||
<p class="sub">유튜브 URL을 등록하면 Opal 숏폼 생성기 결과를 구간별로 저장·조회합니다.</p>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn btn-secondary" onclick="togglePaste()">
|
|
||||||
<i data-lucide="clipboard-paste" style="width:15px;"></i> 결과 붙여넣기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="sf-form" onsubmit="registerJob(event)">
|
|
||||||
<input type="text" id="urlInput" placeholder="https://www.youtube.com/watch?v=..." required>
|
|
||||||
<button class="btn btn-primary" type="submit">큐에 등록</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="sf-paste" id="pasteBox">
|
|
||||||
<input type="text" id="pasteUrl" placeholder="영상 URL" style="margin-bottom:0.5rem;width:100%;">
|
|
||||||
<textarea id="pasteRaw" placeholder="Opal Output 전체를 붙여넣으세요 (1===== ... 5===== 전부)"></textarea>
|
|
||||||
<button class="btn btn-primary" style="margin-top:0.5rem;" onclick="importResult()">파싱해서 저장</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="jobList"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<th:block layout:fragment="script">
|
|
||||||
<script>
|
|
||||||
async function api(path, opts) {
|
|
||||||
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
|
|
||||||
const body = await res.json();
|
|
||||||
if (!body.success) throw new Error(body.message);
|
|
||||||
return body.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePaste() {
|
|
||||||
document.getElementById('pasteBox').classList.toggle('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerJob(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const youtubeUrl = document.getElementById('urlInput').value.trim();
|
|
||||||
try {
|
|
||||||
await api('/api/shortform/jobs', { method: 'POST', body: JSON.stringify({ youtubeUrl }) });
|
|
||||||
document.getElementById('urlInput').value = '';
|
|
||||||
loadJobs();
|
|
||||||
} catch (err) { alert(err.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importResult() {
|
|
||||||
const youtubeUrl = document.getElementById('pasteUrl').value.trim();
|
|
||||||
const rawText = document.getElementById('pasteRaw').value;
|
|
||||||
if (!youtubeUrl || !rawText.trim()) { alert('URL과 결과 원문을 모두 입력하세요'); return; }
|
|
||||||
try {
|
|
||||||
await api('/api/shortform/import', { method: 'POST', body: JSON.stringify({ youtubeUrl, rawText }) });
|
|
||||||
document.getElementById('pasteUrl').value = '';
|
|
||||||
document.getElementById('pasteRaw').value = '';
|
|
||||||
togglePaste();
|
|
||||||
loadJobs();
|
|
||||||
} catch (err) { alert(err.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteJob(id, e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!confirm('이 작업을 삭제할까요?')) return;
|
|
||||||
await api('/api/shortform/jobs/' + id, { method: 'DELETE' });
|
|
||||||
loadJobs();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleJob(id, el) {
|
|
||||||
if (el.classList.contains('open')) { el.classList.remove('open'); return; }
|
|
||||||
const detail = await api('/api/shortform/jobs/' + id);
|
|
||||||
el.querySelector('.sf-clips').innerHTML = detail.clips.length
|
|
||||||
? detail.clips.map(clipHtml).join('')
|
|
||||||
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>';
|
|
||||||
el.classList.add('open');
|
|
||||||
el.querySelectorAll('[data-copy]').forEach(btn => {
|
|
||||||
btn.onclick = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigator.clipboard.writeText(decodeURIComponent(btn.dataset.copy));
|
|
||||||
btn.textContent = '복사됨!';
|
|
||||||
setTimeout(() => btn.textContent = 'capcut2 JSON 복사', 1200);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s) {
|
|
||||||
return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function clipHtml(c) {
|
|
||||||
return `<div class="sf-clip">
|
|
||||||
<div class="sf-clip-head">
|
|
||||||
<span class="no">ID ${c.clipNo}</span>
|
|
||||||
<span>${esc(c.titleTop || '')} / <b>${esc(c.titleMain || '(타이틀 파싱 실패)')}</b></span>
|
|
||||||
<span style="flex:1"></span>
|
|
||||||
<button class="btn btn-secondary" data-copy="${encodeURIComponent(c.capcutJson || '')}"
|
|
||||||
${c.capcutJson ? '' : 'disabled'}>capcut2 JSON 복사</button>
|
|
||||||
</div>
|
|
||||||
${c.titleCandidates ? `<pre>${esc(c.titleCandidates)}</pre>` : ''}
|
|
||||||
${c.durationTable ? `<pre>${esc(c.durationTable)}</pre>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadJobs() {
|
|
||||||
const jobs = await api('/api/shortform/jobs');
|
|
||||||
const list = document.getElementById('jobList');
|
|
||||||
list.innerHTML = jobs.length ? '' : '<p style="color:var(--text-3);">등록된 작업이 없습니다.</p>';
|
|
||||||
jobs.forEach(j => {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'sf-job';
|
|
||||||
el.innerHTML = `<div class="sf-job-head">
|
|
||||||
<img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt="">
|
|
||||||
<div class="sf-job-title">
|
|
||||||
<div class="t">${esc(j.title || j.videoId)}</div>
|
|
||||||
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
|
|
||||||
</div>
|
|
||||||
<span class="sf-badge ${j.status}">${j.status}</span>
|
|
||||||
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)">
|
|
||||||
<i data-lucide="trash-2" style="width:14px;"></i></button>
|
|
||||||
</div>
|
|
||||||
<div class="sf-clips"></div>`;
|
|
||||||
el.querySelector('.sf-job-head').onclick = () => toggleJob(j.id, el);
|
|
||||||
list.appendChild(el);
|
|
||||||
});
|
|
||||||
if (window.lucide) lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadJobs();
|
|
||||||
</script>
|
|
||||||
</th:block>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class ShortformOutputParserTest {
|
|
||||||
|
|
||||||
private final ShortformOutputParser parser = new ShortformOutputParser();
|
|
||||||
|
|
||||||
private String sample() throws Exception {
|
|
||||||
return Files.readString(Path.of("src/test/resources/shortform/opal-output-sample.txt"),
|
|
||||||
StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void 실전_샘플에서_5개_섹션을_모두_파싱한다() throws Exception {
|
|
||||||
List<ParsedClip> clips = parser.parse(sample());
|
|
||||||
|
|
||||||
assertEquals(5, clips.size());
|
|
||||||
for (ParsedClip c : clips) {
|
|
||||||
assertNotNull(c.capcutJson(), "clip " + c.clipNo() + " json");
|
|
||||||
assertTrue(c.capcutJson().contains("\"cuts\""));
|
|
||||||
assertNotNull(c.titleCandidates(), "clip " + c.clipNo() + " titles");
|
|
||||||
assertNotNull(c.durationTable(), "clip " + c.clipNo() + " table");
|
|
||||||
}
|
|
||||||
assertEquals(1, clips.get(0).clipNo());
|
|
||||||
assertEquals(5, clips.get(4).clipNo());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void json펜스가_있는_섹션과_없는_섹션_모두_타이틀을_추출한다() throws Exception {
|
|
||||||
List<ParsedClip> clips = parser.parse(sample());
|
|
||||||
|
|
||||||
// 섹션 1: ```json 펜스 있음
|
|
||||||
assertEquals("프나 신보 감상평", clips.get(0).titleTop());
|
|
||||||
assertEquals("극락 가는 앨범입니다", clips.get(0).titleMain());
|
|
||||||
// 섹션 3: 펜스 없이 생 JSON
|
|
||||||
assertEquals("프나 유치찬란 상황극", clips.get(2).titleTop());
|
|
||||||
// 섹션 5: 펜스 없음 + url에 한글 플레이스홀더
|
|
||||||
assertEquals("맑은 눈의 광기", clips.get(4).titleTop());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void 검산표는_총길이_줄까지_포함한다() throws Exception {
|
|
||||||
List<ParsedClip> clips = parser.parse(sample());
|
|
||||||
assertTrue(clips.get(0).durationTable().contains("총 13컷 / 총 길이: 46.5초"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void 빈_입력은_빈_리스트를_반환한다() {
|
|
||||||
assertTrue(parser.parse(null).isEmpty());
|
|
||||||
assertTrue(parser.parse(" ").isEmpty());
|
|
||||||
assertTrue(parser.parse("구분자가 없는 텍스트").isEmpty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.shortform;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
class YoutubeVideoIdTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void 다양한_유튜브_url_형식에서_videoId를_추출한다() {
|
|
||||||
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/watch?v=JW5mwZ8RsG8").orElseThrow());
|
|
||||||
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/watch?v=JW5mwZ8RsG8&t=10s").orElseThrow());
|
|
||||||
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://youtu.be/JW5mwZ8RsG8").orElseThrow());
|
|
||||||
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/shorts/JW5mwZ8RsG8").orElseThrow());
|
|
||||||
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/embed/JW5mwZ8RsG8").orElseThrow());
|
|
||||||
assertEquals("JW5mwZ8RsG8", YoutubeVideoId.from("https://www.youtube.com/live/JW5mwZ8RsG8?feature=share").orElseThrow());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void 유튜브가_아니거나_id가_없으면_empty() {
|
|
||||||
assertTrue(YoutubeVideoId.from(null).isEmpty());
|
|
||||||
assertTrue(YoutubeVideoId.from("").isEmpty());
|
|
||||||
assertTrue(YoutubeVideoId.from("https://example.com/watch?v=JW5mwZ8RsG8").isEmpty());
|
|
||||||
assertTrue(YoutubeVideoId.from("https://www.youtube.com/watch").isEmpty());
|
|
||||||
assertTrue(YoutubeVideoId.from("https://www.youtube.com/watch?v=short").isEmpty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,448 +0,0 @@
|
|||||||
1================================================ ```json
|
|
||||||
{
|
|
||||||
"url": "https://www.youtube.com/watch?v=kYJ6gVbX680",
|
|
||||||
"title_top": "프나 신보 감상평",
|
|
||||||
"title_main": "극락 가는 앨범입니다",
|
|
||||||
"channel": "@MerryGoRound_Official",
|
|
||||||
"cuts": [
|
|
||||||
{
|
|
||||||
"start": "14:28.000",
|
|
||||||
"end": "14:33.000",
|
|
||||||
"bottom": "1번 트랙부터 10번 트랙까지 듣잖아요?\n극락 가세요",
|
|
||||||
"effect": "(자신감 뿜뿜)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "14:33.000",
|
|
||||||
"end": "14:35.500",
|
|
||||||
"bottom": "아 극락 갑니까?\n네!",
|
|
||||||
"effect": "(빵터짐)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "14:36.000",
|
|
||||||
"end": "14:39.000",
|
|
||||||
"bottom": "저도 극락행 티켓 한 장이요\n살게요 살게요",
|
|
||||||
"effect": "(나도 나도)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "14:44.000",
|
|
||||||
"end": "14:46.500",
|
|
||||||
"bottom": "내친김에 콘서트도..?\n그것도 꼭꼭 약속",
|
|
||||||
"effect": "(영업 성공)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "14:46.500",
|
|
||||||
"end": "14:51.000",
|
|
||||||
"bottom": "콘서트에 여자의 악마 왔대!",
|
|
||||||
"effect": "(폭소)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "15:39.000",
|
|
||||||
"end": "15:45.000",
|
|
||||||
"bottom": "저희가 너무 좋아도\n팬분들이 싫어할 것 같다 하면 빼고",
|
|
||||||
"effect": "(팬사랑 최고)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "15:48.000",
|
|
||||||
"end": "15:51.000",
|
|
||||||
"bottom": "프로미스나인 팬은 정말 좋겠다",
|
|
||||||
"effect": "(부러움 가득)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "15:51.000",
|
|
||||||
"end": "15:54.000",
|
|
||||||
"bottom": "잘해줄게",
|
|
||||||
"effect": "(심쿵 플러팅)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "15:56.000",
|
|
||||||
"end": "15:59.000",
|
|
||||||
"bottom": "이 분은 왜 진심으로 부러워하세요",
|
|
||||||
"effect": "(팩폭)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "16:00.000",
|
|
||||||
"end": "16:03.500",
|
|
||||||
"bottom": "근데 유닛으로 이렇게 나눴지만\n유닛 명이 따로 있나?",
|
|
||||||
"effect": "(궁금)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "16:04.000",
|
|
||||||
"end": "16:08.000",
|
|
||||||
"bottom": "그냥 없는데 저희끼리는 채나지",
|
|
||||||
"effect": "(채나지?)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "16:08.500",
|
|
||||||
"end": "16:11.500",
|
|
||||||
"bottom": "어 근데 다 너무 예쁘다\n채나지도 예쁘고 하지도 예쁘고",
|
|
||||||
"effect": "(오 정말?)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "16:11.500",
|
|
||||||
"end": "16:15.000",
|
|
||||||
"bottom": "안녕하세요 하지입니다~",
|
|
||||||
"effect": "(인사봇)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
📌 타이틀 후보 5선
|
|
||||||
1. 상단: 프나 신보 감상평 / 메인: 극락 가는 앨범입니다 — [어그로형]
|
|
||||||
2. 상단: 오직 팬 생각뿐 / 메인: 팬들이 싫어하면 빼요 — [감동형]
|
|
||||||
3. 상단: 유닛명 비하인드 / 메인: 안녕하세요 하지입니다 — [유머형]
|
|
||||||
4. 상단: 10년 차 아이돌 / 메인: 팬사랑 폼 미쳤다 — [바이럴형]
|
|
||||||
5. 상단: 프미스나인 컴백 / 메인: 노래 맛집의 플러팅 — [클릭유도형]
|
|
||||||
|
|
||||||
⏱️ 컷 길이 검산표
|
|
||||||
컷 1: 14:28.000 → 14:33.000 = 5.0초
|
|
||||||
컷 2: 14:33.000 → 14:35.500 = 2.5초
|
|
||||||
컷 3: 14:36.000 → 14:39.000 = 3.0초
|
|
||||||
컷 4: 14:44.000 → 14:46.500 = 2.5초
|
|
||||||
컷 5: 14:46.500 → 14:51.000 = 4.5초
|
|
||||||
컷 6: 15:39.000 → 15:45.000 = 6.0초
|
|
||||||
컷 7: 15:48.000 → 15:51.000 = 3.0초
|
|
||||||
컷 8: 15:51.000 → 15:54.000 = 3.0초
|
|
||||||
컷 9: 15:56.000 → 15:59.000 = 3.0초
|
|
||||||
컷 10: 16:00.000 → 16:03.500 = 3.5초
|
|
||||||
컷 11: 16:04.000 → 16:08.000 = 4.0초
|
|
||||||
컷 12: 16:08.500 → 16:11.500 = 3.0초
|
|
||||||
컷 13: 16:11.500 → 16:15.000 = 3.5초
|
|
||||||
─────────────────────
|
|
||||||
총 13컷 / 총 길이: 46.5초
|
|
||||||
2================================================
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"url": "https://www.youtube.com/watch?v=k-a2Wd7F7Q8",
|
|
||||||
"title_top": "과자 깠다고 칭찬?",
|
|
||||||
"title_main": "숨막히는 프나 칭찬 지옥",
|
|
||||||
"channel": "@MerryGoRound_official",
|
|
||||||
"cuts": [
|
|
||||||
{
|
|
||||||
"start": "21:25.000",
|
|
||||||
"end": "21:32.000",
|
|
||||||
"bottom": "우와 너 멋있다\n그걸 깠어?",
|
|
||||||
"effect": "(무한 칭찬)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "21:37.500",
|
|
||||||
"end": "21:42.500",
|
|
||||||
"bottom": "나 이거 혼자 깠어",
|
|
||||||
"effect": "(어깨 으쓱)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "21:51.000",
|
|
||||||
"end": "21:57.000",
|
|
||||||
"bottom": "웃으면 안 돼요\n보아하니 제일 큰 벌칙이에요",
|
|
||||||
"effect": "(제일 힘듦)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "22:07.500",
|
|
||||||
"end": "22:14.500",
|
|
||||||
"bottom": "나경아 너 오늘\n코에 점 되게 섹시하다",
|
|
||||||
"effect": "(느끼한 톤)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "22:14.500",
|
|
||||||
"end": "22:19.500",
|
|
||||||
"bottom": "아 미치겠다 진짜",
|
|
||||||
"effect": "(빵 터짐)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "22:24.000",
|
|
||||||
"end": "22:30.500",
|
|
||||||
"bottom": "칭찬받아서 웃긴 게 아니라\n톤이 웃기잖아요",
|
|
||||||
"effect": "(급발진)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "22:37.500",
|
|
||||||
"end": "22:41.500",
|
|
||||||
"bottom": "챙아 아까부터 느꼈는데",
|
|
||||||
"effect": "(진지)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "22:42.500",
|
|
||||||
"end": "22:49.500",
|
|
||||||
"bottom": "오늘 옷 너무 잘 어울린다\n섹시해",
|
|
||||||
"effect": "(결국 함박웃음)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
📌 타이틀 후보 5선
|
|
||||||
|
|
||||||
상단: 과자 깠다고 칭찬? / 메인: 숨막히는 프나 칭찬 지옥 — [바이럴형]
|
|
||||||
상단: 코에 점이 섹시해 / 메인: 웃음 참기 TMI 칭찬 — [어그로형]
|
|
||||||
상단: 유인나도 빵터진 / 메인: 프로미스나인 유치찬란 — [클릭유도형]
|
|
||||||
상단: 과자 까면 영웅 됨 / 메인: 프나의 이상한 칭찬법 — [바이럴형]
|
|
||||||
상단: 마의 구간 코 점 / 메인: 웃으면 안되는 칭찬 릴레이 — [어그로형]
|
|
||||||
|
|
||||||
⏱️ 컷 길이 검산표
|
|
||||||
컷 1: 21:25.000 → 21:32.000 = 7.0초
|
|
||||||
컷 2: 21:37.500 → 21:42.500 = 5.0초
|
|
||||||
컷 3: 21:51.000 → 21:57.000 = 6.0초
|
|
||||||
컷 4: 22:07.500 → 22:14.500 = 7.0초
|
|
||||||
컷 5: 22:14.500 → 22:19.500 = 5.0초
|
|
||||||
컷 6: 22:24.000 → 22:30.500 = 6.5초
|
|
||||||
컷 7: 22:37.500 → 22:41.500 = 4.0초
|
|
||||||
컷 8: 22:42.500 → 22:49.500 = 7.0초
|
|
||||||
─────────────────────
|
|
||||||
총 8컷 / 총 길이: 47.5초
|
|
||||||
3================================================
|
|
||||||
{
|
|
||||||
"url": "",
|
|
||||||
"title_top": "프나 유치찬란 상황극",
|
|
||||||
"title_main": "35만 원인데 그거 짭이다",
|
|
||||||
"channel": "",
|
|
||||||
"cuts": [
|
|
||||||
{
|
|
||||||
"start": "26:13.000",
|
|
||||||
"end": "26:18.500",
|
|
||||||
"bottom": "이거 35만 원인데 그거 짭이다\n저는 이 끈이랑 목걸이만 말한 거궁",
|
|
||||||
"effect": "(순살 멘트)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "24:46.000",
|
|
||||||
"end": "24:51.200",
|
|
||||||
"bottom": "음~\n나경 씨가 찐 맛 평가단이네",
|
|
||||||
"effect": "(진지한 미식가)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "24:51.200",
|
|
||||||
"end": "24:55.800",
|
|
||||||
"bottom": "저도 맛있는데 맛 표현을 못 해요\n표정만 보면 미슐랭 평가단인데!",
|
|
||||||
"effect": "(미슐랭 쓰리스타 급)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "25:05.000",
|
|
||||||
"end": "25:11.800",
|
|
||||||
"bottom": "너 오늘 코에 점 되게 섹시하다\n칭찬 받아서 웃긴 게 아니라 톤이",
|
|
||||||
"effect": "(플러팅 공격)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "25:13.200",
|
|
||||||
"end": "25:18.000",
|
|
||||||
"bottom": "오늘 옷 너무 잘 어울린다 섹시해\n이렇게 하는 거 맞아요?",
|
|
||||||
"effect": "(영혼 없는 칭찬)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "25:56.500",
|
|
||||||
"end": "26:04.500",
|
|
||||||
"bottom": "오늘 렌즈 색깔 좀 톤그로다\n아니 그걸 왜 이제 얘기해줘 진짜로!",
|
|
||||||
"effect": "(매운맛 비난)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "26:04.500",
|
|
||||||
"end": "26:08.500",
|
|
||||||
"bottom": "무슨 소리야 진짜 잘 맞아!!",
|
|
||||||
"effect": "(발끈)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "26:09.500",
|
|
||||||
"end": "26:13.000",
|
|
||||||
"bottom": "아니 어디서 얼마에 샀냐구용\n나는 3만 9천 원에 샀다 왜요!",
|
|
||||||
"effect": "(티키타카)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "26:18.500",
|
|
||||||
"end": "26:22.500",
|
|
||||||
"bottom": "제 생각엔 제가 짭인 것 같아요\n두 분 다 과소비가 심하네!",
|
|
||||||
"effect": "(빠른 패배 인정)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "26:24.000",
|
|
||||||
"end": "26:30.500",
|
|
||||||
"bottom": "텐션 미쳤다 셋이서 오예~ 오예~\n오예! 오예!",
|
|
||||||
"effect": "(대환장 파티)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
📌 타이틀 후보 5선
|
|
||||||
|
|
||||||
상단: 프나 유치찬란 상황극 / 메인: 35만 원인데 그거 짭이다 — [어그로형]
|
|
||||||
상단: 웃음 참고 칭찬하기 / 메인: 코에 점 되게 섹시하다 — [바이럴형]
|
|
||||||
상단: 프로미스나인 매운맛 / 메인: 오늘 렌즈 색깔 톤그로다 — [바이럴형]
|
|
||||||
상단: 과몰입 장인 프로미스나인 / 메인: 제발 그만해 너무 유치해 — [클릭유도형]
|
|
||||||
상단: 미슐랭 미식가 나경 / 메인: 표정에서 드러나는 맛 평가 — [클릭유도형]
|
|
||||||
|
|
||||||
⏱️ 컷 길이 검산표
|
|
||||||
컷 1: 26:13.000 → 26:18.500 = 5.5초
|
|
||||||
컷 2: 24:46.000 → 24:51.200 = 5.2초
|
|
||||||
컷 3: 24:51.200 → 24:55.800 = 4.6초
|
|
||||||
컷 4: 25:05.000 → 25:11.800 = 6.8초
|
|
||||||
컷 5: 25:13.200 → 25:18.000 = 4.8초
|
|
||||||
컷 6: 25:56.500 → 26:04.500 = 8.0초
|
|
||||||
컷 7: 26:04.500 → 26:08.500 = 4.0초
|
|
||||||
컷 8: 26:09.500 → 26:13.000 = 3.5초
|
|
||||||
컷 9: 26:18.500 → 26:22.500 = 4.0초
|
|
||||||
컷 10: 26:24.000 → 26:30.500 = 6.5초
|
|
||||||
─────────────────────
|
|
||||||
총 10컷 / 총 길이: 52.9초
|
|
||||||
4================================================
|
|
||||||
{
|
|
||||||
"url": "",
|
|
||||||
"title_top": "프미나 미식가",
|
|
||||||
"title_main": "손동작 킹받는 과자 리뷰",
|
|
||||||
"channel": "",
|
|
||||||
"cuts": [
|
|
||||||
{
|
|
||||||
"start": "29:24.000",
|
|
||||||
"end": "29:32.500",
|
|
||||||
"bottom": "신중하게 좀 드시다가\n음~ 음 손동작 킹받아",
|
|
||||||
"effect": "(미식가 포스)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "29:09.000",
|
|
||||||
"end": "29:17.500",
|
|
||||||
"bottom": "세 봉지 연속으로 먹을 수 있어\n무조건 음~ 음! 오오!",
|
|
||||||
"effect": "(폭풍 흡입)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "29:40.000",
|
|
||||||
"end": "29:48.000",
|
|
||||||
"bottom": "글씨를 보고 좀 겁먹었거든요\n근데 그렇게 맵지 않아요",
|
|
||||||
"effect": "(맵찔이도 가능)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "30:08.500",
|
|
||||||
"end": "30:15.500",
|
|
||||||
"bottom": "저희 팀 대표 맵찔이인데\n마지노선 정말 맛있는 매운맛",
|
|
||||||
"effect": "(맵찔이 인증)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "30:29.500",
|
|
||||||
"end": "30:36.500",
|
|
||||||
"bottom": "신기한 게 치즈 그림이 있잖아요\n진짜 로제에 치즈 올린 맛이 나요",
|
|
||||||
"effect": "(완벽 재현)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "30:43.500",
|
|
||||||
"end": "30:52.000",
|
|
||||||
"bottom": "성분표를 보고 있습니다\n나경 씨는 제가 봤을 때 미식가예요",
|
|
||||||
"effect": "(냉철한 분석)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
📌 타이틀 후보 5선
|
|
||||||
|
|
||||||
상단: 프미나 미식가 / 메인: 손동작 킹받는 과자 리뷰 — [바이럴형]
|
|
||||||
상단: 세 봉지 순삭 / 메인: 맵찔이도 반한 포테토칩 — [클릭유도형]
|
|
||||||
상단: 로제 치즈맛 완벽 / 메인: 엽떡 로제맛 과자의 정체 — [어그로형]
|
|
||||||
상단: 성분표 분석까지 / 메인: 과자 리뷰에 진심인 아이돌 — [바이럴형]
|
|
||||||
상단: 맵찔이 마지노선 / 메인: 맛있게 매운 신상 과자 먹방 — [클릭유도형]
|
|
||||||
|
|
||||||
⏱️ 컷 길이 검산표
|
|
||||||
컷 1: 29:24.000 → 29:32.500 = 8.5초
|
|
||||||
컷 2: 29:09.000 → 29:17.500 = 8.5초
|
|
||||||
컷 3: 29:40.000 → 29:48.000 = 8.0초
|
|
||||||
컷 4: 30:08.500 → 30:15.500 = 7.0초
|
|
||||||
컷 5: 30:29.500 → 30:36.500 = 7.0초
|
|
||||||
컷 6: 30:43.500 → 30:52.000 = 8.5초
|
|
||||||
─────────────────────
|
|
||||||
총 6컷 / 총 길이: 47.5초
|
|
||||||
5================================================
|
|
||||||
{
|
|
||||||
"url": "https://www.youtube.com/watch?v=비공개_혹은_알수없는_ID",
|
|
||||||
"title_top": "맑은 눈의 광기",
|
|
||||||
"title_main": "차분하게 팩폭하는 나경",
|
|
||||||
"channel": "",
|
|
||||||
"cuts": [
|
|
||||||
{
|
|
||||||
"start": "31:26.500",
|
|
||||||
"end": "31:30.000",
|
|
||||||
"bottom": "같이 점심을 먹으려는데\n각자 먹고 싶은 메뉴가 다르다",
|
|
||||||
"effect": "(상황극 시작)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "31:56.500",
|
|
||||||
"end": "32:00.000",
|
|
||||||
"bottom": "저는 삼겹살",
|
|
||||||
"effect": "(최애 메뉴)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:01.500",
|
|
||||||
"end": "32:06.000",
|
|
||||||
"bottom": "나는 어린이 돈가스\n이 집 어린이 돈가스가 진짜 맛있어",
|
|
||||||
"effect": "(뜬금포)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:08.500",
|
|
||||||
"end": "32:14.500",
|
|
||||||
"bottom": "여긴 삼겹살 전문점인데?\n- 여기 간판 조만간 바뀔걸?",
|
|
||||||
"effect": "(아무말 우기기)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:17.500",
|
|
||||||
"end": "32:20.500",
|
|
||||||
"bottom": "차라리 나중에\n돈가스 전문점 가서 먹는 거 어때?",
|
|
||||||
"effect": "(차분한 타격)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:20.500",
|
|
||||||
"end": "32:25.500",
|
|
||||||
"bottom": "돈가스 전문점은 다 똑같아\n여기가 되게 특별해",
|
|
||||||
"effect": "(억지)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:25.500",
|
|
||||||
"end": "32:32.000",
|
|
||||||
"bottom": "근데 여기 고기 먹어봤어?\n- 아니?\n너~무 맛있어! 굉장히 스페셜해",
|
|
||||||
"effect": "(홀리는 중)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:32.000",
|
|
||||||
"end": "32:36.500",
|
|
||||||
"bottom": "돈가스랑 김치찌개 시키자!\n- 기름기랑 같이 들어가야 돼서",
|
|
||||||
"effect": "(철벽)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:36.500",
|
|
||||||
"end": "32:41.000",
|
|
||||||
"bottom": "삼겹살을 또 먹어줘야 돼\n- 내가 쌈 싸줄게 그래!",
|
|
||||||
"effect": "(홀라당 넘어감)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:41.000",
|
|
||||||
"end": "32:45.000",
|
|
||||||
"bottom": "저희 같은 성격이\n나경이 절대 못 이겨요",
|
|
||||||
"effect": "(감탄)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "32:47.500",
|
|
||||||
"end": "32:53.000",
|
|
||||||
"bottom": "나경 씨 얼굴을 보고 있으면\n자체 클로즈업이 돼요 자꾸",
|
|
||||||
"effect": "(미인계)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"start": "33:01.500",
|
|
||||||
"end": "33:05.500",
|
|
||||||
"bottom": "저는 개입해서 한마디 하려고 했어\n진작 설득당했어 나는",
|
|
||||||
"effect": "(어이없음)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
📌 타이틀 후보 5선
|
|
||||||
|
|
||||||
상단: 삼겹살 전문점인데 / 메인: 돈가스 시키자는 유인나 — 어그로형
|
|
||||||
상단: 맑은 눈의 광기 / 메인: 차분하게 팩폭하는 나경 — 바이럴형
|
|
||||||
상단: 무논리 vs 차분함 / 메인: 결국 삼겹살에 굴복한 언니 — 바이럴형
|
|
||||||
상단: 미모가 무기 / 메인: 자체 클로즈업 되는 나경 얼굴 — 어그로형
|
|
||||||
상단: 절대 못 이기는 성격 / 메인: 프로미스나인 나경의 설득법 — 클릭유도형
|
|
||||||
|
|
||||||
⏱️ 컷 길이 검산표
|
|
||||||
컷 1: 31:26.500 → 31:30.000 = 3.5초
|
|
||||||
컷 2: 31:56.500 → 32:00.000 = 3.5초
|
|
||||||
컷 3: 32:01.500 → 32:06.000 = 4.5초
|
|
||||||
컷 4: 32:08.500 → 32:14.500 = 6.0초
|
|
||||||
컷 5: 32:17.500 → 32:20.500 = 3.0초
|
|
||||||
컷 6: 32:20.500 → 32:25.500 = 5.0초
|
|
||||||
컷 7: 32:25.500 → 32:32.000 = 6.5초
|
|
||||||
컷 8: 32:32.000 → 32:36.500 = 4.5초
|
|
||||||
컷 9: 32:36.500 → 32:41.000 = 4.5초
|
|
||||||
컷 10: 32:41.000 → 32:45.000 = 4.0초
|
|
||||||
컷 11: 32:47.500 → 32:53.000 = 5.5초
|
|
||||||
컷 12: 33:01.500 → 33:05.500 = 4.0초
|
|
||||||
─────────────────────
|
|
||||||
총 12컷 / 총 길이: 54.5초
|
|
||||||
Loading…
Reference in New Issue
Block a user