Compare commits

...

3 Commits

Author SHA1 Message Date
b48ab6494c 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>
2026-08-04 10:31:25 +09:00
13dd61d80c feat: 소재 피드에서 영상을 사이트 안에서 재생
썸네일·제목을 누르면 YouTube 새 탭으로 나가던 걸 레이어 팝업 재생으로 바꿨다.
쇼츠는 세로(9:16), 롱폼은 가로(16:9)로 비율을 맞추고, 팝업 안에 숏폼 큐 등록과
YouTube 열기를 함께 뒀다. Escape·배경 클릭으로 닫히고 닫을 때 재생이 멈춘다.

임베드를 막아둔 채널이 많다는 걸 실측했다 — 소재 원본 9건 중 4건이 차단이고
채널 단위로 갈린다(짠한형 전부 차단, 뜬뜬·스프 전부 가능). 그대로 두면 절반이
"동영상을 재생할 수 없음" 화면을 보게 되므로:

- 수집할 때 videos.list 에 status 파트를 붙여 embeddable 을 저장한다(쿼터 동일).
- 차단된 영상은 팝업 대신 바로 YouTube 새 탭으로 연다.
- 차단 영상 썸네일에 외부링크 아이콘을 띄워 눌렀을 때 나가는 걸 예고한다.
- embeddable 이 null(미수집)이면 일단 재생을 시도한다 — 팝업 안에 YouTube 링크가
  있어 막혀도 빠져나갈 수 있다.

브라우저에서 확인: 재생 가능 영상은 팝업에서 실제 재생됐고(썸네일·제목 클릭 모두),
차단 채널 카드에만 외부링크 아이콘이 붙었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:16:52 +09:00
e9d3f9a9f4 feat: 소재 피드에서 숏폼 큐로 바로 보내기
발굴한 롱폼을 숏폼 큐로 넘기는 동선이 없어 URL을 손으로 옮겨야 했다.
채널 등록·롱폼 수집·정보 표시는 이미 소재 피드에 있으므로 페이지를 새로
만들지 않고 카드에 버튼만 붙였다.

- 소재 원본/인물 탭 카드의 주 버튼을 '숏폼 큐로'로 바꿨다. 경쟁 쇼츠는 이미
  잘린 결과물이라 큐 대상이 아니라 원본 보기만 남긴다.
- 피드를 열 때 큐 목록을 함께 받아 이미 담긴 영상은 '큐에 있음'으로 표시하고
  숏폼 큐로 링크한다. 등록 API가 videoId 기준 멱등이라 중복 클릭도 안전하다.
- 기존 재가공 스튜디오 진입은 아이콘 버튼으로 남겼다.
- 큐 목록 조회가 실패해도 피드 자체는 정상 동작한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:13:51 +09:00
11 changed files with 231 additions and 29 deletions

View File

@ -17,7 +17,8 @@ h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Dev
b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭. b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭.
c. **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬). 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. **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이 항상 실패했음). 4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약. 5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.

View File

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

View File

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

View File

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

View File

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

View File

@ -35,12 +35,12 @@ public class ShortformController {
@PostMapping("/jobs/{id}/result") @PostMapping("/jobs/{id}/result")
public ApiResponse<JobDetail> saveResult(@PathVariable Long id, @RequestBody ResultRequest request) { 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") @PostMapping("/import")
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) { 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}") @DeleteMapping("/jobs/{id}")

View File

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

View File

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

View File

@ -12,9 +12,9 @@ public final class ShortformDtos {
public record RegisterRequest(String youtubeUrl) {} 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, public record JobSummary(Long id, String youtubeUrl, String videoId, String title,
String status, int clipCount, String status, int clipCount,
@ -36,11 +36,11 @@ public final class ShortformDtos {
public record JobDetail(Long id, String youtubeUrl, String videoId, String title, public record JobDetail(Long id, String youtubeUrl, String videoId, String title,
String status, LocalDateTime createdAt, LocalDateTime completedAt, String status, LocalDateTime createdAt, LocalDateTime completedAt,
String rawOutput, List<ClipDto> clips) { String rawOutput, String step1Output, List<ClipDto> clips) {
public static JobDetail from(ShortformJob job) { public static JobDetail from(ShortformJob job) {
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(), return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
job.getTitle(), job.getStatus().name(), job.getCreatedAt(), job.getTitle(), job.getStatus().name(), job.getCreatedAt(),
job.getCompletedAt(), job.getRawOutput(), job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
job.getClips().stream().map(ClipDto::from).toList()); job.getClips().stream().map(ClipDto::from).toList());
} }
} }

View File

@ -169,6 +169,21 @@
</div> </div>
</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> <div id="toast" class="toast" role="status" aria-live="polite"></div>
@ -201,7 +216,11 @@
.fcard.is-worked { opacity:.55; } .fcard.is-worked { opacity:.55; }
.fcard.is-worked:hover { opacity:1; } .fcard.is-worked:hover { opacity:1; }
.fthumb { position:relative; display:block; background:var(--inset); aspect-ratio:16/9; } /* 썸네일·제목은 버튼이지만 링크처럼 보여야 하므로 기본 버튼 스타일을 지운다 */
.fthumb {
position:relative; display:block; background:var(--inset); aspect-ratio:16/9;
width:100%; padding:0; border:0; cursor:pointer;
}
.fthumb img { width:100%; height:100%; object-fit:cover; display:block; } .fthumb img { width:100%; height:100%; object-fit:cover; display:block; }
.fthumb:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } .fthumb:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.fdur { .fdur {
@ -217,6 +236,14 @@
font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5; font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5;
} }
.fgolden svg { width:11px; height:11px; } .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; } .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; } .fmeta { display:flex; align-items:center; gap:0.35rem; font-size:0.72rem; color:var(--text-3); min-width:0; }
@ -224,8 +251,11 @@
.ftitle { .ftitle {
font-size:0.87rem; font-weight:600; line-height:1.4; color:var(--text); 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; 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: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 { 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; } .fstats .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
@ -299,6 +329,18 @@
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; 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) { @media (prefers-reduced-motion: reduce) {
.fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; } .fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; }
} }
@ -308,9 +350,12 @@
/*<![CDATA[*/ /*<![CDATA[*/
const API = '/api/feed'; const API = '/api/feed';
const CV_API = '/api/v1/channel-videos'; const CV_API = '/api/v1/channel-videos';
const SF_API = '/api/shortform';
let currentTab = 'SOURCE'; let currentTab = 'SOURCE';
let items = []; 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;'); } function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
@ -526,28 +571,41 @@
const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null) const vph = (currentTab === 'RIVAL' && it.viewsPerHour != null)
? `<span>·</span><span class="num">${fmtNum(Math.round(it.viewsPerHour))}/h</span>` : ''; ? `<span>·</span><span class="num">${fmtNum(Math.round(it.viewsPerHour))}/h</span>` : '';
// 롱폼 소재(소스·인물)는 재가공 스튜디오로, 경쟁 쇼츠는 원본 보기로 // 롱폼 소재(소스·인물)는 숏폼 큐로 넘기는 게 주 동선이다.
// (남의 쇼츠는 재가공 대상이 아님) // 경쟁 쇼츠는 이미 잘린 결과물이라 큐 대상이 아니고 원본 보기만 준다.
const inQueue = queued.has(it.videoId);
const primary = currentTab === 'RIVAL' const primary = currentTab === 'RIVAL'
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener"> ? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>` <i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`
: `<a class="btn btn-primary grow" href="/rework/${it.id}"> : (inQueue
<i data-lucide="wand-2" style="width:14px;"></i> 작업 시작</a>`; ? `<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>`
: '';
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}"> return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
<a class="fthumb" href="${url}" target="_blank" rel="noopener" <button class="fthumb" type="button" onclick="openVideo(${it.id})"
aria-label="${esc(it.title)} — YouTube 에서 열기"> aria-label="${esc(it.title)} — ${blocked ? 'YouTube 에서 열기' : '여기서 재생'}">
<img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270"> <img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270">
${golden} ${golden}
${playHint}
${dur ? `<span class="fdur">${dur}</span>` : ''} ${dur ? `<span class="fdur">${dur}</span>` : ''}
</a> </button>
<div class="fbody"> <div class="fbody">
<div class="fmeta"> <div class="fmeta">
<span class="fprog">${esc(it.channelTitle || '-')}</span> <span class="fprog">${esc(it.channelTitle || '-')}</span>
<span>·</span> <span>·</span>
<span>${fmtAgo(it.publishedAt)}</span> <span>${fmtAgo(it.publishedAt)}</span>
</div> </div>
<a class="ftitle" href="${url}" target="_blank" rel="noopener">${esc(it.title)}</a> <button class="ftitle" type="button" onclick="openVideo(${it.id})">${esc(it.title)}</button>
<div class="fstats"> <div class="fstats">
<span class="num">${fmtNum(it.viewCount)}회</span>${vph} <span class="num">${fmtNum(it.viewCount)}회</span>${vph}
${badgeHtml(it)} ${badgeHtml(it)}
@ -555,6 +613,11 @@
</div> </div>
<div class="factions"> <div class="factions">
${primary} ${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="북마크" <button class="icon-btn${it.bookmarked ? ' on' : ''}" title="북마크"
aria-label="북마크" aria-pressed="${it.bookmarked}" aria-label="북마크" aria-pressed="${it.bookmarked}"
onclick="toggleBookmark(${it.id}, ${!!it.bookmarked})"> onclick="toggleBookmark(${it.id}, ${!!it.bookmarked})">
@ -593,6 +656,93 @@
if(window.lucide) lucide.createIcons(); 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){ async function toggleBookmark(id, current){
try { try {
@ -862,13 +1012,13 @@
} }
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if(e.key === 'Escape'){ closeSeeds(); closePersons(); } if(e.key === 'Escape'){ closeVideo(); closeSeeds(); closePersons(); }
}); });
// ---------- init ---------- // ---------- init ----------
(async () => { (async () => {
const preselect = restoreFilters(); const preselect = restoreFilters();
await loadFilterOptions(preselect); await Promise.all([loadFilterOptions(preselect), loadQueued()]);
await loadFeed(); await loadFeed();
})(); })();
/*]]>*/ /*]]>*/

View File

@ -107,10 +107,19 @@
<button class="btn btn-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}" <button class="btn btn-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}"
data-label="전체 Output 복사 (capcut2 오팔 JSON 탭용)" data-label="전체 Output 복사 (capcut2 오팔 JSON 탭용)"
${detail.rawOutput ? '' : 'disabled'}>전체 Output 복사 (capcut2 오팔 JSON 탭용)</button> ${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 || '')}" <button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
data-label="영상 URL 복사">영상 URL 복사</button> data-label="영상 URL 복사">영상 URL 복사</button>
</div>`; </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('') ? detail.clips.map(clipHtml).join('')
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>'); : '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
el.classList.add('open'); el.classList.add('open');