feat: 숏폼 큐 파서 - 1단계 candidates JSON도 클립으로 인식
- Opal "숏폼 생성기 1단계" 출력(candidates JSON)을 N===== 구분자 매칭 실패 시 폴백으로 파싱해 DONE 처리 - 후보 원본 JSON은 capcutJson 자리에 보존, 시간·경계·선정이유는 durationTable 요약으로 표시 - 실전 픽스처 opal-step1-candidates-sample.txt 추가 - 낡은 테스트 정리: ChannelRole.acceptsShorts 제거 반영, PersonPicks 쇼츠 컷 180초 반영 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d4547056e3
commit
88179bb620
@ -40,9 +40,49 @@ public class ShortformOutputParser {
|
||||
ParsedClip clip = parseSection(nos.get(i), raw.substring(starts.get(i), to));
|
||||
if (clip != null) result.add(clip);
|
||||
}
|
||||
if (result.isEmpty()) result.addAll(parseCandidates(raw));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* "숏폼 생성기 1단계" 앱 출력: N===== 구분자 없이 {"candidates":[...]} JSON 하나.
|
||||
* 후보마다 원본 JSON을 capcutJson 자리에 보존하고, 시간 정보는 durationTable 로 요약한다.
|
||||
*/
|
||||
private List<ParsedClip> parseCandidates(String raw) {
|
||||
List<ParsedClip> result = new ArrayList<>();
|
||||
String json = extractJson(raw);
|
||||
if (json == null) return result;
|
||||
try {
|
||||
JsonNode arr = OM.readTree(json).path("candidates");
|
||||
int fallbackNo = 0;
|
||||
for (JsonNode c : arr) {
|
||||
fallbackNo++;
|
||||
String table = candidateTable(c);
|
||||
result.add(new ParsedClip(
|
||||
c.path("id").asInt(fallbackNo),
|
||||
OM.writerWithDefaultPrettyPrinter().writeValueAsString(c),
|
||||
null,
|
||||
table,
|
||||
c.path("title_top").asText(null),
|
||||
c.path("title_main").asText(null)));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// candidates 형식이 아니면 빈 리스트 — 원문은 rawOutput에 보존된다
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String candidateTable(JsonNode c) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("구간 ").append(c.path("start_time").asText("?"))
|
||||
.append(" ~ ").append(c.path("end_time").asText("?"))
|
||||
.append(" (").append(c.path("duration_sec").asText("?")).append("초)")
|
||||
.append(" · 훅 ").append(c.path("hook_point").asText("?"));
|
||||
if (c.hasNonNull("boundary_check")) sb.append("\n경계: ").append(c.path("boundary_check").asText());
|
||||
if (c.hasNonNull("reason")) sb.append("\n선정 이유: ").append(c.path("reason").asText());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private ParsedClip parseSection(int no, String body) {
|
||||
String json = extractJson(body);
|
||||
String titles = extractBetween(body, "타이틀 후보", "검산표");
|
||||
|
||||
@ -28,12 +28,6 @@ class ChannelRoleTest {
|
||||
assertThat(ChannelRole.isFeed(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsShorts_경쟁채널만_쇼츠_소스채널은_롱폼() {
|
||||
assertThat(ChannelRole.acceptsShorts(ChannelRole.RIVAL)).isTrue();
|
||||
assertThat(ChannelRole.acceptsShorts(ChannelRole.SOURCE)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void OWN은_별도_역할이며_isFeed가_아니다() {
|
||||
assertThat(ChannelRole.normalize("own")).isEqualTo(ChannelRole.OWN);
|
||||
|
||||
@ -20,8 +20,8 @@ class PersonPicksTest {
|
||||
void 쇼츠는_버리고_롱폼만_남긴다() {
|
||||
List<PersonPicks.Found> found = List.of(
|
||||
f("a", 30, "C1", NOW), // 쇼츠
|
||||
f("b", 65, "C1", NOW), // 경계값 65초 = 쇼츠
|
||||
f("c", 66, "C1", NOW), // 롱폼
|
||||
f("b", 180, "C1", NOW), // 경계값 180초(유튜브 쇼츠 최대) = 쇼츠
|
||||
f("c", 181, "C1", NOW), // 롱폼
|
||||
f("d", 4300, "C1", NOW)); // 롱폼
|
||||
|
||||
List<PersonPicks.Found> kept = PersonPicks.keep(found, Set.of(), null, 0);
|
||||
|
||||
@ -52,6 +52,27 @@ class ShortformOutputParserTest {
|
||||
assertTrue(clips.get(0).durationTable().contains("총 13컷 / 총 길이: 46.5초"));
|
||||
}
|
||||
|
||||
/** "숏폼 생성기 1단계" 앱 출력: N===== 구분자 없이 candidates JSON 하나 (실전 픽스처). */
|
||||
private String step1Sample() throws Exception {
|
||||
return Files.readString(Path.of("src/test/resources/shortform/opal-step1-candidates-sample.txt"),
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidates_JSON_출력은_후보를_클립으로_파싱한다() throws Exception {
|
||||
List<ParsedClip> clips = parser.parse(step1Sample());
|
||||
|
||||
assertEquals(5, clips.size());
|
||||
ParsedClip first = clips.get(0);
|
||||
assertEquals(1, first.clipNo());
|
||||
assertEquals("냉동창고 알바생들의 현실 꼼수", first.titleTop());
|
||||
assertEquals("극한직업 뷔페 알바 첫 미션", first.titleMain());
|
||||
assertTrue(first.capcutJson().contains("\"start_time\""), "후보 원본 JSON 보존");
|
||||
assertTrue(first.durationTable().contains("02:35"));
|
||||
assertTrue(first.durationTable().contains("05:07"));
|
||||
assertEquals(5, clips.get(4).clipNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 빈_입력은_빈_리스트를_반환한다() {
|
||||
assertTrue(parser.parse(null).isEmpty());
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"id": 1,
|
||||
"start_time": "02:35",
|
||||
"end_time": "05:07",
|
||||
"duration_sec": 152,
|
||||
"hook_point": "02:49",
|
||||
"boundary_check": "인사하며 냉동창고에 들어가는 시점에서 시작, 추위에 떨며 밖으로 도망쳐 나오는 상황 종료 시점에서 끝",
|
||||
"reason": "영케이와 츄의 티키타카가 돋보이는 구간으로, 냉동창고라는 극한의 환경 속에서 꼼수(?)를 부리는 알바생들의 현실적인 리액션과 몸개그가 큰 웃음을 유발함.",
|
||||
"title_top": "냉동창고 알바생들의 현실 꼼수",
|
||||
"title_main": "극한직업 뷔페 알바 첫 미션"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"start_time": "08:28",
|
||||
"end_time": "10:33",
|
||||
"duration_sec": 125,
|
||||
"hook_point": "08:34",
|
||||
"boundary_check": "오픈 전 인사를 연습하는 시점에서 시작, 손님 응대 후 한숨 돌리는 문장 완결 지점에서 끝",
|
||||
"reason": "오픈런 손님들을 맞이하기 위해 만반의 준비(이상한 포즈)를 했으나, 음식에 직진하는 손님들에게 철저히 투명인간 취급을 당하는 굴욕적인 상황이 완벽한 코미디를 완성함.",
|
||||
"title_top": "알바생이 투명인간이 되는 마법",
|
||||
"title_main": "오픈런 손님들의 뷔페 돌진"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"start_time": "11:42",
|
||||
"end_time": "13:14",
|
||||
"duration_sec": 92,
|
||||
"hook_point": "11:53",
|
||||
"boundary_check": "테이블 번호를 못 찾아 헤매는 시점에서 시작, 주방으로 다시 쫓겨 들어가는 혼미한 상황 종료 지점에서 끝",
|
||||
"reason": "복잡한 뷔페 홀에서 테이블 번호를 찾지 못해 넓은 홀 한가운데서 생목으로 샤우팅을 지르는 영케이의 다급함과 민망함이 시청자들에게 폭소를 안김.",
|
||||
"title_top": "뷔페 한가운데서 생목 샤우팅",
|
||||
"title_main": "길 잃은 신입 알바의 최후"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"start_time": "15:43",
|
||||
"end_time": "17:24",
|
||||
"duration_sec": 101,
|
||||
"hook_point": "15:52",
|
||||
"boundary_check": "손님의 레시피를 구경하는 시점에서 시작, 얻어먹은 멜론을 숨기려다 점장에게 걸리는 완벽한 콩트 완결 후 끝",
|
||||
"reason": "손님이 만든 기발한 디저트를 얻어먹기 위해 데이식스 영케이가 직접 라이브 라이브 헌정쇼를 펼치고, 이후 남은 멜론을 은닉하려다 걸리는 반전까지 버릴 곳 없는 알짜배기 구간.",
|
||||
"title_top": "손님 디저트 뺏어먹는 아이돌",
|
||||
"title_main": "멜론 한 조각을 위한 미니 콘서트"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"start_time": "19:48",
|
||||
"end_time": "21:22",
|
||||
"duration_sec": 94,
|
||||
"hook_point": "19:59",
|
||||
"boundary_check": "영케이가 군대 이야기를 꺼내는 시점에서 시작, JYP 연습생 시절 댄스 배틀에 대한 회상을 마무리하는 지점에서 끝",
|
||||
"reason": "설거지 지옥에 빠져 멘탈이 나간 상태에서 해병대 vs 공군 드립을 치는 웃픈 상황과 더불어, 대형 기획사(JYP) 연습생 시절 댄스 배틀을 피해 화장실에 숨었던 과거 썰이 흥미를 자극함.",
|
||||
"title_top": "JYP 전설의 연습생(?)의 비밀",
|
||||
"title_main": "설거지 지옥에서 풀린 과거 썰"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user