Compare commits

..

No commits in common. "b48ab6494c6fbdfe55e847dd7f89bf57c7b400eb" and "9a960be66390ccabc9752dc64773af3f23b08076" have entirely different histories.

11 changed files with 29 additions and 231 deletions

View File

@ -17,8 +17,7 @@ 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자 단위로 나눠 회수한다.
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개 미만이면 원문과 함께 보고.
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` 링크로 요약.

View File

@ -307,8 +307,7 @@ public class ChannelService {
private int upsertVideos(Channel channel, List<String> videoIds, String source,
Boolean shortsOnly, LocalDateTime publishedAfter) {
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
// status 임베드 가능 여부(embeddable) 때문에 필요하다. part 늘려도 쿼터는 그대로다.
.queryParam("part", "snippet,statistics,contentDetails,status")
.queryParam("part", "snippet,statistics,contentDetails")
.queryParam("id", String.join(",", videoIds))
.queryParam("key", youtubeApiKey)
.toUriString();
@ -351,9 +350,6 @@ public class ChannelService {
// 해시태그는 시드 역분석( 채널 소재 원본 채널 후보) 재료가 된다.
String hashtags = HashtagExtractor.join(
HashtagExtractor.extract(snippet.path("description").asText("") + " " + title));
// 임베드 차단 채널이 많아(방송사·연예 채널) 미리 알아둬야 헛클릭을 막는다
final Boolean embeddable = item.path("status").has("embeddable")
? item.path("status").path("embeddable").asBoolean() : null;
channelVideoRepository.findByVideoId(videoId)
.ifPresentOrElse(v -> {
@ -361,7 +357,6 @@ public class ChannelService {
v.applyMetrics(durationSec, isShorts, viewsPerHour);
v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
v.applyHashtags(hashtags);
v.applyEmbeddable(embeddable);
channelVideoRepository.save(v);
}, () -> {
ChannelVideo newVideo = ChannelVideo.builder()
@ -377,7 +372,6 @@ public class ChannelService {
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
newVideo.applyHashtags(hashtags);
newVideo.applyEmbeddable(embeddable);
channelVideoRepository.save(newVideo);
});
saved++;

View File

@ -82,14 +82,6 @@ public class ChannelVideo {
@Column(name = "hashtags", columnDefinition = "TEXT")
private String hashtags;
/**
* 다른 사이트에 임베드할 있는가(YouTube status.embeddable).
* 방송사·연예 채널은 막아둔 경우가 많아(실측 9건 4건) 미리 알아야 헛클릭을 막는다.
* null 이면 아직 모름 일단 재생을 시도한다.
*/
@Column(name = "embeddable")
private Boolean embeddable;
/**
* 인물 추적으로 걸린 영상이면 인물명. 인물 탭은 source 아니라 값으로 조회하므로,
* 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 모두에 나타난다.
@ -187,11 +179,6 @@ public class ChannelVideo {
this.hashtags = hashtags;
}
/** 임베드 가능 여부를 기록한다. */
public void applyEmbeddable(Boolean embeddable) {
this.embeddable = embeddable;
}
/** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */
public void applyContextSummary(String contextSummary) {
this.contextSummary = contextSummary;

View File

@ -138,8 +138,7 @@ public class PersonCollectionService {
}
if (videoIds.isEmpty()) return 0;
Map<String, Boolean> embeddable = new LinkedHashMap<>();
List<PersonPicks.Found> found = fetchDetails(videoIds, embeddable);
List<PersonPicks.Found> found = fetchDetails(videoIds);
List<PersonPicks.Found> picks = PersonPicks.keep(found, exclude, publishedAfter, minViewsPerHour);
int saved = 0;
@ -152,24 +151,19 @@ public class PersonCollectionService {
v.update(f.title(), v.getThumbnailUrl(), f.viewCount(), v.getLikeCount());
v.applyMetrics(f.durationSec(), VideoMetrics.isShorts(f.durationSec()), vph);
v.applyMatchedPerson(person);
v.applyEmbeddable(embeddable.get(f.videoId()));
channelVideoRepository.save(v);
}, () -> {
ChannelVideo nv = ChannelVideo.fromPersonSearch(
f.videoId(), f.title(), thumbnailOf(f.videoId()), f.publishedAt(), f.viewCount(),
f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person);
nv.applyEmbeddable(embeddable.get(f.videoId()));
channelVideoRepository.save(nv);
});
}, () -> channelVideoRepository.save(ChannelVideo.fromPersonSearch(
f.videoId(), f.title(), thumbnailOf(f.videoId()), f.publishedAt(), f.viewCount(),
f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person)));
saved++;
}
return saved;
}
/** videos.list 로 길이·조회수·업로드일을 채운다(검색 결과에는 길이가 없다). */
private List<PersonPicks.Found> fetchDetails(List<String> videoIds, Map<String, Boolean> embeddableOut) {
private List<PersonPicks.Found> fetchDetails(List<String> videoIds) {
URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
.queryParam("part", "snippet,contentDetails,statistics,status")
.queryParam("part", "snippet,contentDetails,statistics")
.queryParam("id", String.join(",", videoIds))
.queryParam("key", youtubeApiKey)
.build().encode().toUri();
@ -186,9 +180,6 @@ public class PersonCollectionService {
LocalDateTime publishedAt = LocalDateTime.parse(
snippet.path("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME);
long views = item.path("statistics").path("viewCount").asLong(0);
if (item.path("status").has("embeddable")) {
embeddableOut.put(item.path("id").asText(), item.path("status").path("embeddable").asBoolean());
}
out.add(new PersonPicks.Found(
item.path("id").asText(), snippet.path("title").asText(""),
snippet.path("channelId").asText(null), snippet.path("channelTitle").asText(""),

View File

@ -26,8 +26,6 @@ public record FeedItemDto(
String source,
/** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */
String matchedPerson,
/** 사이트 안에서 재생 가능한가. null 이면 아직 모름 — 일단 재생을 시도한다. */
Boolean embeddable,
/** 업로드 24시간 이내 — 선점 골든타임. */
boolean goldenTime,
/** SHORTS | CLIP | FULL | UNKNOWN */
@ -53,7 +51,6 @@ public record FeedItemDto(
v.isBookmarked(),
v.getSource(),
v.getMatchedPerson(),
v.getEmbeddable(),
FeedBadges.isGoldenTime(v.getPublishedAt(), now),
FeedBadges.lengthBucket(v.getDurationSec()),
FeedBadges.isRising(v.getViewsPerHour()),

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(), request.step1Text()));
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(), request.step1Text()));
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText()));
}
@DeleteMapping("/jobs/{id}")

View File

@ -43,10 +43,6 @@ 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;
@ -75,12 +71,6 @@ 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, String step1Text) {
public JobDetail saveResult(Long jobId, String rawText) {
ShortformJob job = jobRepository.findById(jobId)
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
List<ParsedClip> parsed = parser.parse(rawText);
@ -36,15 +36,14 @@ public class ShortformService {
} else {
job.applyResult(rawText, parsed);
}
job.attachStep1(step1Text);
return JobDetail.from(job);
}
/** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */
@Transactional
public JobDetail importResult(String youtubeUrl, String rawText, String step1Text) {
public JobDetail importResult(String youtubeUrl, String rawText) {
JobDetail registered = register(youtubeUrl);
return saveResult(registered.id(), rawText, step1Text);
return saveResult(registered.id(), rawText);
}
@Transactional(readOnly = true)

View File

@ -12,9 +12,9 @@ public final class ShortformDtos {
public record RegisterRequest(String youtubeUrl) {}
public record ResultRequest(String rawText, String step1Text) {}
public record ResultRequest(String rawText) {}
public record ImportRequest(String youtubeUrl, String rawText, String step1Text) {}
public record ImportRequest(String youtubeUrl, String rawText) {}
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, String step1Output, List<ClipDto> clips) {
String rawOutput, 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.getStep1Output(),
job.getCompletedAt(), job.getRawOutput(),
job.getClips().stream().map(ClipDto::from).toList());
}
}

View File

@ -169,21 +169,6 @@
</div>
</div>
<!-- 영상 미리보기 -->
<div id="videoModal" class="modal-overlay" onclick="if(event.target===this) closeVideo()">
<div class="modal-card modal-wide" role="dialog" aria-modal="true" aria-labelledby="vmTitle">
<div class="modal-head">
<h3 id="vmTitle" class="truncate" style="padding-right:0.5rem;">영상</h3>
<button class="modal-close" id="vmClose" onclick="closeVideo()" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div id="vmFrame" class="vm-frame"></div>
<p class="text-xs text-muted mt-2" id="vmMeta"></p>
<div class="vm-actions" id="vmActions"></div>
</div>
</div>
</div>
<!-- 토스트 -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
@ -216,11 +201,7 @@
.fcard.is-worked { opacity:.55; }
.fcard.is-worked:hover { opacity:1; }
/* 썸네일·제목은 버튼이지만 링크처럼 보여야 하므로 기본 버튼 스타일을 지운다 */
.fthumb {
position:relative; display:block; background:var(--inset); aspect-ratio:16/9;
width:100%; padding:0; border:0; cursor:pointer;
}
.fthumb { position:relative; display:block; background:var(--inset); aspect-ratio:16/9; }
.fthumb img { width:100%; height:100%; object-fit:cover; display:block; }
.fthumb:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.fdur {
@ -236,14 +217,6 @@
font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5;
}
.fgolden svg { width:11px; height:11px; }
/* 외부 재생 표시 — 눌렀을 때 YouTube 로 나간다는 예고 */
.fext {
position:absolute; right:6px; top:6px;
display:inline-flex; align-items:center; justify-content:center;
width:22px; height:22px; border-radius:5px;
background:rgba(0,0,0,.72); color:#fff;
}
.fext svg { width:12px; height:12px; }
.fbody { padding:0.7rem 0.8rem 0.55rem; display:flex; flex-direction:column; gap:0.35rem; flex:1; }
.fmeta { display:flex; align-items:center; gap:0.35rem; font-size:0.72rem; color:var(--text-3); min-width:0; }
@ -251,11 +224,8 @@
.ftitle {
font-size:0.87rem; font-weight:600; line-height:1.4; color:var(--text);
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
width:100%; padding:0; border:0; background:none; text-align:left;
font-family:inherit; cursor:pointer;
}
.ftitle:hover { text-decoration:underline; }
.ftitle:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:2px; }
.fstats { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; font-size:0.72rem; color:var(--text-3); }
.fstats .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
@ -329,18 +299,6 @@
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
}
/* ===== 영상 미리보기 ===== */
.modal-wide { max-width:900px; }
.vm-frame {
width:100%; aspect-ratio:16/9; background:#000;
border-radius:var(--r-sm); overflow:hidden;
}
/* 쇼츠는 세로라 화면을 다 먹지 않게 높이를 묶는다 */
.vm-frame.portrait { aspect-ratio:9/16; max-height:70vh; width:auto; margin:0 auto; }
.vm-frame iframe { width:100%; height:100%; border:0; display:block; }
.vm-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.8rem; }
.vm-actions .btn { min-height:40px; }
@media (prefers-reduced-motion: reduce) {
.fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; }
}
@ -350,12 +308,9 @@
/*<![CDATA[*/
const API = '/api/feed';
const CV_API = '/api/v1/channel-videos';
const SF_API = '/api/shortform';
let currentTab = 'SOURCE';
let items = [];
/** 숏폼 큐에 이미 담긴 videoId — 중복 등록을 막고 카드에 표시한다. */
let queued = new Set();
// ---------- 공통 ----------
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
@ -571,41 +526,28 @@
const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null)
? `<span>·</span><span class="num">${fmtNum(Math.round(it.viewsPerHour))}/h</span>` : '';
// 롱폼 소재(소스·인물)는 숏폼 큐로 넘기는 게 주 동선이다.
// 경쟁 쇼츠는 이미 잘린 결과물이라 큐 대상이 아니고 원본 보기만 준다.
const inQueue = queued.has(it.videoId);
// 롱폼 소재(소스·인물)는 재가공 스튜디오로, 경쟁 쇼츠는 원본 보기로
// (남의 쇼츠는 재가공 대상이 아님)
const primary = currentTab === 'RIVAL'
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`
: (inQueue
? `<a class="btn btn-secondary grow" href="/shortform">
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
: `<button class="btn btn-primary grow" id="sfBtn-${it.id}"
onclick="sendToQueue(${it.id})">
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
// 임베드 차단 영상은 눌렀을 때 YouTube 로 나가므로 미리 알려준다
const blocked = it.embeddable === false;
const playHint = blocked
? `<span class="fext" title="이 채널은 외부 재생을 막아둬서 YouTube 로 열립니다">
<i data-lucide="external-link"></i></span>`
: '';
: `<a class="btn btn-primary grow" href="/rework/${it.id}">
<i data-lucide="wand-2" style="width:14px;"></i> 작업 시작</a>`;
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
<button class="fthumb" type="button" onclick="openVideo(${it.id})"
aria-label="${esc(it.title)} — ${blocked ? 'YouTube 에서 열기' : '여기서 재생'}">
<a class="fthumb" href="${url}" target="_blank" rel="noopener"
aria-label="${esc(it.title)} — YouTube 에서 열기">
<img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270">
${golden}
${playHint}
${dur ? `<span class="fdur">${dur}</span>` : ''}
</button>
</a>
<div class="fbody">
<div class="fmeta">
<span class="fprog">${esc(it.channelTitle || '-')}</span>
<span>·</span>
<span>${fmtAgo(it.publishedAt)}</span>
</div>
<button class="ftitle" type="button" onclick="openVideo(${it.id})">${esc(it.title)}</button>
<a class="ftitle" href="${url}" target="_blank" rel="noopener">${esc(it.title)}</a>
<div class="fstats">
<span class="num">${fmtNum(it.viewCount)}회</span>${vph}
${badgeHtml(it)}
@ -613,11 +555,6 @@
</div>
<div class="factions">
${primary}
${currentTab === 'RIVAL' ? '' : `
<a class="icon-btn" href="/rework/${it.id}" title="재가공 스튜디오"
aria-label="재가공 스튜디오에서 열기">
<i data-lucide="wand-2" style="width:16px;"></i>
</a>`}
<button class="icon-btn${it.bookmarked ? ' on' : ''}" title="북마크"
aria-label="북마크" aria-pressed="${it.bookmarked}"
onclick="toggleBookmark(${it.id}, ${!!it.bookmarked})">
@ -656,93 +593,6 @@
if(window.lucide) lucide.createIcons();
}
// ---------- 영상 미리보기 ----------
let lastFocused = null;
function openVideo(id){
const it = items.find(v => v.id === id);
if(!it) return;
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(it.videoId);
// 임베드를 막아둔 채널이 많다(실측 9건 중 4건). 깨진 플레이어를 보여주느니
// 바로 YouTube 로 보낸다. embeddable 이 null 이면 아직 모르니 일단 시도한다.
if(it.embeddable === false){
window.open(url, '_blank', 'noopener');
return;
}
lastFocused = document.activeElement;
document.getElementById('vmTitle').textContent = it.title;
// 쇼츠는 세로, 나머지는 가로
const frame = document.getElementById('vmFrame');
frame.classList.toggle('portrait', it.lengthBucket === 'SHORTS');
frame.innerHTML = `<iframe src="https://www.youtube.com/embed/${encodeURIComponent(it.videoId)}?autoplay=1&rel=0"
title="${esc(it.title)}" allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
allowfullscreen></iframe>`;
const dur = fmtDur(it.durationSec);
document.getElementById('vmMeta').textContent =
`${it.channelTitle || '-'} · ${fmtAgo(it.publishedAt)} · ${fmtNum(it.viewCount)}회${dur ? ' · ' + dur : ''}`;
// 임베드가 막힌 영상이 있어 YouTube 로 여는 길을 항상 열어둔다
const inQueue = queued.has(it.videoId);
const queueBtn = currentTab === 'RIVAL' ? '' : (inQueue
? `<a class="btn btn-secondary" href="/shortform">
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
: `<button class="btn btn-primary" onclick="sendToQueue(${it.id}); closeVideo();">
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
document.getElementById('vmActions').innerHTML = `
${queueBtn}
<a class="btn btn-secondary" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> YouTube 에서 열기</a>`;
document.getElementById('videoModal').classList.add('open');
if(window.lucide) lucide.createIcons();
document.getElementById('vmClose').focus();
}
function closeVideo(){
const modal = document.getElementById('videoModal');
if(!modal.classList.contains('open')) return;
modal.classList.remove('open');
document.getElementById('vmFrame').innerHTML = ''; // 재생 중지
if(lastFocused && lastFocused.focus) lastFocused.focus();
lastFocused = null;
}
// ---------- 숏폼 큐 ----------
/** 큐에 이미 담긴 videoId 를 받아둔다. 실패해도 피드 자체는 보여야 하므로 조용히 넘긴다. */
async function loadQueued(){
try {
const jobs = await api(SF_API + '/jobs') || [];
queued = new Set(jobs.map(j => j.videoId).filter(Boolean));
} catch(e){ /* 큐를 못 읽어도 피드는 정상 동작 */ }
}
async function sendToQueue(id){
const it = items.find(v => v.id === id);
if(!it) return;
const btn = document.getElementById('sfBtn-' + id);
if(btn){
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" style="width:14px;" class="animate-spin"></i> 보내는 중...';
if(window.lucide) lucide.createIcons();
}
try {
await api(SF_API + '/jobs', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ youtubeUrl: 'https://www.youtube.com/watch?v=' + it.videoId })
});
queued.add(it.videoId);
render();
toast('숏폼 큐에 담았습니다 — 숏폼 큐에서 Opal 실행하세요');
} catch(e){
toast('숏폼 큐 등록 실패: ' + e.message, true);
render();
}
}
// ---------- 카드 액션 ----------
async function toggleBookmark(id, current){
try {
@ -1012,13 +862,13 @@
}
document.addEventListener('keydown', e => {
if(e.key === 'Escape'){ closeVideo(); closeSeeds(); closePersons(); }
if(e.key === 'Escape'){ closeSeeds(); closePersons(); }
});
// ---------- init ----------
(async () => {
const preselect = restoreFilters();
await Promise.all([loadFilterOptions(preselect), loadQueued()]);
await loadFilterOptions(preselect);
await loadFeed();
})();
/*]]>*/

View File

@ -107,19 +107,10 @@
<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>`;
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
el.querySelector('.sf-clips').innerHTML = toolbar + (detail.clips.length
? detail.clips.map(clipHtml).join('')
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
el.classList.add('open');