feat: 숏폼 큐에 Step 1 후보구간 원문 저장·복사 추가

ShortformJob.step1Output(TEXT) + result/import API에 step1Text 옵션 필드.
카드 펼침에 'Step 1 후보구간 복사' 버튼과 접이식 원문 표시.
shortform-queue 스킬에 콘솔 Step 1 추출 절차(d-2) 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 10:31:14 +09:00
parent 13dd61d80c
commit b48ab6494c
6 changed files with 32 additions and 11 deletions

View File

@ -17,7 +17,8 @@ h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Dev
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개 미만이면 원문과 함께 보고.
d-2. **Step 1 결과 추출**: Console 탭에서 "Step 1: 하이라이트 후보 구간 추출" 항목을 UI 클릭으로 펼친 뒤(lazy 렌더라 JS로 details.open만 하면 내용이 안 붙을 수 있음 — 안 되면 화면 좌표 클릭), shadow DOM 관통으로 `"candidates"`를 포함한 **가장 짧은** innerText(Model Response 부분)를 회수한다.
e. **저장**: `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<Output 원문>", "step1Text": "<Step 1 원문>"}`. step1Text는 추출 실패 시 생략 가능(비파괴). 응답의 clips가 5개 미만이면 원문과 함께 보고.
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.

View File

@ -35,12 +35,12 @@ public class ShortformController {
@PostMapping("/jobs/{id}/result")
public ApiResponse<JobDetail> saveResult(@PathVariable Long id, @RequestBody ResultRequest request) {
return ApiResponse.ok(shortformService.saveResult(id, request.rawText()));
return ApiResponse.ok(shortformService.saveResult(id, request.rawText(), request.step1Text()));
}
@PostMapping("/import")
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) {
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText()));
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(), request.step1Text()));
}
@DeleteMapping("/jobs/{id}")

View File

@ -43,6 +43,10 @@ public class ShortformJob {
@Column(columnDefinition = "TEXT")
private String rawOutput;
/** Step 1(하이라이트 후보 구간 추출) 원문 — 구간 선정 이유가 담겨 최종 Output과 별개로 보존한다. */
@Column(columnDefinition = "TEXT")
private String step1Output;
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime createdAt;
@ -71,6 +75,12 @@ public class ShortformJob {
this.completedAt = LocalDateTime.now();
}
public void attachStep1(String step1Text) {
if (step1Text != null && !step1Text.isBlank()) {
this.step1Output = step1Text;
}
}
public void markFailed(String raw) {
this.rawOutput = raw;
this.status = ShortformJobStatus.FAILED;

View File

@ -27,7 +27,7 @@ public class ShortformService {
/** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */
@Transactional
public JobDetail saveResult(Long jobId, String rawText) {
public JobDetail saveResult(Long jobId, String rawText, String step1Text) {
ShortformJob job = jobRepository.findById(jobId)
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
List<ParsedClip> parsed = parser.parse(rawText);
@ -36,14 +36,15 @@ public class ShortformService {
} else {
job.applyResult(rawText, parsed);
}
job.attachStep1(step1Text);
return JobDetail.from(job);
}
/** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */
@Transactional
public JobDetail importResult(String youtubeUrl, String rawText) {
public JobDetail importResult(String youtubeUrl, String rawText, String step1Text) {
JobDetail registered = register(youtubeUrl);
return saveResult(registered.id(), rawText);
return saveResult(registered.id(), rawText, step1Text);
}
@Transactional(readOnly = true)

View File

@ -12,9 +12,9 @@ public final class ShortformDtos {
public record RegisterRequest(String youtubeUrl) {}
public record ResultRequest(String rawText) {}
public record ResultRequest(String rawText, String step1Text) {}
public record ImportRequest(String youtubeUrl, String rawText) {}
public record ImportRequest(String youtubeUrl, String rawText, String step1Text) {}
public record JobSummary(Long id, String youtubeUrl, String videoId, String title,
String status, int clipCount,
@ -36,11 +36,11 @@ public final class ShortformDtos {
public record JobDetail(Long id, String youtubeUrl, String videoId, String title,
String status, LocalDateTime createdAt, LocalDateTime completedAt,
String rawOutput, List<ClipDto> clips) {
String rawOutput, String step1Output, 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.getRawOutput(),
job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
job.getClips().stream().map(ClipDto::from).toList());
}
}

View File

@ -107,10 +107,19 @@
<button class="btn btn-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}"
data-label="전체 Output 복사 (capcut2 오팔 JSON 탭용)"
${detail.rawOutput ? '' : 'disabled'}>전체 Output 복사 (capcut2 오팔 JSON 탭용)</button>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.step1Output || '')}"
data-label="Step 1 후보구간 복사"
${detail.step1Output ? '' : 'disabled'}>Step 1 후보구간 복사</button>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
data-label="영상 URL 복사">영상 URL 복사</button>
</div>`;
el.querySelector('.sf-clips').innerHTML = toolbar + (detail.clips.length
const step1Block = detail.step1Output
? `<details class="sf-clip" style="margin-bottom:0.6rem;">
<summary style="cursor:pointer;font-weight:600;">Step 1: 하이라이트 후보 구간 (선정 이유 포함)</summary>
<pre>${esc(detail.step1Output)}</pre>
</details>`
: '';
el.querySelector('.sf-clips').innerHTML = toolbar + step1Block + (detail.clips.length
? detail.clips.map(clipHtml).join('')
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
el.classList.add('open');