Compare commits
3 Commits
9a960be663
...
b48ab6494c
| Author | SHA1 | Date | |
|---|---|---|---|
| b48ab6494c | |||
| 13dd61d80c | |||
| e9d3f9a9f4 |
@ -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` 링크로 요약.
|
||||||
|
|
||||||
|
|||||||
@ -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++;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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(""),
|
||||||
|
|||||||
@ -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()),
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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="닫기">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
@ -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();
|
||||||
})();
|
})();
|
||||||
/*]]>*/
|
/*]]>*/
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user