Compare commits

...

9 Commits

Author SHA1 Message Date
de30174cd1 feat: shortform-queue 스킬 — Opal 브라우저 대행 절차 명문화
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:30 +09:00
be96f68f6f feat: 숏폼 큐 페이지 — URL 등록·결과 붙여넣기·클립 카드·JSON 복사
사이드바 소재 섹션에 '숏폼 큐' 메뉴 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:30 +09:00
db6a9831b6 feat: 숏폼 큐 서비스·REST API — 등록/조회/결과저장/import/삭제
status=PENDING 필터는 shortform-queue 스킬의 큐 조회용.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
005df3225b feat: 숏폼 큐 엔티티(ShortformJob/Clip)와 리포지토리
rawOutput은 파싱 성공 여부와 무관하게 원문 보존. videoId 유니크로 중복 등록 방지.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
3fda9077a6 feat: 유튜브 URL videoId 추출 유틸
watch/shorts/embed/live/youtu.be 지원, 11자 ID 검증.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
0142a439d3 feat: Opal 숏폼 출력 파서 — 구간별 JSON·타이틀·검산표 추출
실전 픽스처 기반: json 펜스 유무, 중괄호 균형 스캔, 문자열 내 이스케이프 처리.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
ca9c6464fc docs: 숏폼 큐 구현 계획 (7개 태스크)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
ef31818d8c test: Opal 숏폼 생성기 실제 출력 샘플 픽스처 추가
파서 개발용. 섹션 3~5는 json 펜스 없음, 타이틀 후보 번호 유무 혼재 등
실전 변형이 포함된 2026-08-03 실행 결과 원문.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
23fadea5e5 docs: 숏폼 큐(Opal 연동) 설계 스펙
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:39:29 +09:00
19 changed files with 2301 additions and 0 deletions

View File

@ -0,0 +1,28 @@
---
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

View File

@ -0,0 +1,62 @@
# 숏폼 큐 — 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 앱으로의 직접 전송(현행 수동 붙여넣기 유지)

View File

@ -0,0 +1,11 @@
package com.hlab.yanalyst.domain.shortform;
/** Opal 출력에서 파싱한 구간 하나. capcutJson 등 원문 조각은 파싱 실패해도 보존한다. */
public record ParsedClip(
int clipNo,
String capcutJson,
String titleCandidates,
String durationTable,
String titleTop,
String titleMain
) {}

View File

@ -0,0 +1,51 @@
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;
}
}

View File

@ -0,0 +1,51 @@
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);
}
}

View File

@ -0,0 +1,79 @@
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();
}
}

View File

@ -0,0 +1,12 @@
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);
}

View File

@ -0,0 +1,3 @@
package com.hlab.yanalyst.domain.shortform;
public enum ShortformJobStatus { PENDING, DONE, FAILED }

View File

@ -0,0 +1,114 @@
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;
}
}

View File

@ -0,0 +1,67 @@
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);
}
}

View File

@ -0,0 +1,21 @@
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();
}
}

View File

@ -0,0 +1,46 @@
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());
}
}
}

View File

@ -64,6 +64,12 @@ 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");

View File

@ -24,6 +24,9 @@
<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'">

View File

@ -0,0 +1,166 @@
<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>

View File

@ -0,0 +1,61 @@
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());
}
}

View File

@ -0,0 +1,27 @@
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());
}
}

View File

@ -0,0 +1,448 @@
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초