Critical: wrapOf가 ci를 몰라 '선택한 것만 보기' 중 배정 안 된(=display:none) 쌍둥이를 캡처하는 조용한 실패가 있었다. wrapOf(hlId,idx,ci)로 정확한 복사본을 찾게 하고, buildAll에서 selonly를 이중으로 꺼서 방어한다. 칩 클릭 스크롤도 같은 방식으로 고쳤다. Important: cardSection.render()가 컷 판정 없이 링을 붙여 '더보기'·검색 재렌더 때 증상이 되살아났다 — refreshSel과 같은 판정식을 render()에 직접 넣었다(패널이 아직 document에 안 붙어 있는 초기 구성 중에도 정확히 동작해야 하므로 refreshSel 재호출 대신 선택). Important: 검색이 섹션 단위로만 display를 토글해 닫힌 컷 아코디언 안의 결과가 안 보였다. 검색 중엔 그 패널의 컷을 전부 펼치고, 지우면 검색 시작 전 열림 상태로 되돌린다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
950 lines
45 KiB
JavaScript
950 lines
45 KiB
JavaScript
/* 자동 탭 — 분석(SSE) → 검토(ID별 탭에서 카드 선택) → 순차 빌드.
|
||
카드 DOM·캡처 방식은 h-lab comment-cards 와 동일 계열(modern-screenshot, 4배). */
|
||
(function(){
|
||
const $=(s)=>document.querySelector(s);
|
||
let A=null; // 분석 result {highlights, comments, warnings}
|
||
let byIdx={}; // idx → comment
|
||
let sel={}; // hl.id → idx 배열(선택 순서 유지: matched 먼저)
|
||
let curId=null; // 현재 보고 있는 하이라이트 ID
|
||
let dropped=new Set(); // 사용자가 X로 제외한 ID(문자열) — 빌드에서 제외. 되돌리기 가능
|
||
const isDropped=(id)=>dropped.has(String(id));
|
||
|
||
/* ── h-lab timeAgo 이식 ── */
|
||
function timeAgo(iso){
|
||
if(!iso) return "";
|
||
const then=new Date(iso).getTime(); if(isNaN(then)) return "";
|
||
const sec=Math.floor((Date.now()-then)/1000);
|
||
const u=[["년",31536000],["개월",2592000],["일",86400],["시간",3600],["분",60]];
|
||
for(const [l,s] of u){const v=Math.floor(sec/s); if(v>=1) return v+l+" 전";}
|
||
return "방금 전";
|
||
}
|
||
function esc(s){const d=document.createElement("div");d.textContent=String(s==null?"":s);return d.innerHTML;}
|
||
function fmtT(sec){const m=Math.floor(sec/60),s=Math.floor(sec%60);return m+":"+String(s).padStart(2,"0");}
|
||
let YT_HL=null; // 유튜브 구간 탭의 가상 하이라이트 {id:"yt", need}
|
||
function hlById(id){
|
||
if(id==="yt") return YT_HL;
|
||
return A.highlights.find(h=>h.id===id);
|
||
}
|
||
|
||
/* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */
|
||
function cardEl(c,hlId,ci){
|
||
const wrap=document.createElement("div");
|
||
wrap.className="ccwrap"; wrap.dataset.cidx=c.idx;
|
||
// 컷 소속을 DOM에 남긴다 — 없으면(폴백/구간 탭) 어느 복사본이 어느 컷 것인지 구분 불가해
|
||
// refreshSel 이 모든 복사본에 선택 링을 붙이는 버그가 난다(컷마다 후보 전체가 반복 렌더되므로).
|
||
if(ci!==undefined) wrap.dataset.ci=ci;
|
||
const card=document.createElement("div");
|
||
card.className="comment-card mosaic"; // 검정배경은 CSS 기본값
|
||
const av=c.profileImageUrl?"/auto/avatar?url="+encodeURIComponent(c.profileImageUrl):"";
|
||
card.innerHTML=
|
||
'<div class="cc-head">'+
|
||
'<img class="cc-avatar" loading="lazy" decoding="async" alt="" src="'+av+'">'+
|
||
'<div class="cc-meta">'+
|
||
'<div><span class="cc-author"></span><span class="cc-time"></span></div>'+
|
||
'<div class="cc-text"></div>'+
|
||
'<div class="cc-stats"><span>👍 '+(c.likeCount||0).toLocaleString()+'</span>'+
|
||
'<span>💬 '+(c.replyCount||0).toLocaleString()+'</span></div>'+
|
||
'</div></div>';
|
||
card.querySelector(".cc-author").textContent=c.authorName||"";
|
||
card.querySelector(".cc-time").textContent=timeAgo(c.publishedAt);
|
||
// textDisplay(HTML) → 평문 (h-lab toPlainText와 동일)
|
||
const tmp=document.createElement("div");
|
||
tmp.innerHTML=String(c.text||"").replace(/<br\s*\/?>/gi,"\n");
|
||
card.querySelector(".cc-text").textContent=tmp.textContent||"";
|
||
const badge=document.createElement("div");
|
||
badge.className="ccbadge"; badge.textContent="✓";
|
||
wrap.appendChild(card); wrap.appendChild(badge);
|
||
wrap.addEventListener("click",()=>toggle(hlId,c.idx,ci));
|
||
return wrap;
|
||
}
|
||
|
||
/* ci를 주면 그 컷의 복사본을 우선으로 찾는다. 안 주거나 못 찾으면 DOM에서 처음 만나는
|
||
복사본으로 폴백(예전 동작·폴백 패널). ci를 안 주고 querySelector 하나만 쓰면 '선택한
|
||
것만 보기'가 켜졌을 때 배정 안 된(=display:none인) 쌍둥이가 먼저 잡혀 캡처가 빈
|
||
이미지가 되거나 스크롤이 숨은 카드로 튀는 사고가 난다 — 반드시 ci를 넘겨라. */
|
||
function wrapOf(hlId,idx,ci){
|
||
const q="#hlbox-"+hlId+" .ccwrap[data-cidx='"+idx+"']";
|
||
return (ci!==undefined&&$(q+"[data-ci='"+ci+"']"))||$(q);
|
||
}
|
||
|
||
/* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */
|
||
const CARD_PAGE=30;
|
||
// '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 DOM 폭증을 막는다
|
||
const CUT_FILL_PAGE=6;
|
||
/* 검색용 평문(본문+작성자, 소문자) — 댓글마다 1회 계산해 캐시 */
|
||
function searchStr(c){
|
||
if(c._st===undefined){
|
||
const tmp=document.createElement("div");
|
||
tmp.innerHTML=String(c.text||"").replace(/<br\s*\/?>/gi,"\n");
|
||
c._st=((tmp.textContent||"")+" "+(c.authorName||"")).toLowerCase();
|
||
}
|
||
return c._st;
|
||
}
|
||
const SECS={}; // hlId → [setFilter,…] (searchBar 가 패널 생성 때 초기화)
|
||
function cardSection(label,list,hlId,firstBatch,ci){
|
||
const sec=document.createElement("div");sec.className="hlsec";
|
||
const head=document.createElement("div");head.textContent=label;
|
||
sec.appendChild(head);
|
||
const grid=document.createElement("div");grid.className="cardsec";
|
||
sec.appendChild(grid);
|
||
const more=document.createElement("button");
|
||
more.type="button";more.className="ghost cc-more";
|
||
const first=firstBatch||CARD_PAGE;
|
||
let cur=list,shown=0,baseShown=0; // baseShown: 검색 전 렌더 수 — 해제 시 복원해야
|
||
// 선택 카드가 DOM에 남는다(캡처는 wrapOf 로 DOM에서 찾음)
|
||
function render(batch){
|
||
const end=Math.min(cur.length,shown+batch);
|
||
for(;shown<end;shown++){
|
||
const c=byIdx[cur[shown]];
|
||
if(!c) continue;
|
||
const w=cardEl(c,hlId,ci);
|
||
// refreshSel과 같은 규칙(배정 컷 없음=폴백, 있으면 이 섹션의 컷과 같을 때만 링).
|
||
// 이 섹션의 모든 카드는 같은 ci를 공유하므로 DOM dataset을 다시 읽을 필요 없이
|
||
// 클로저의 ci로 바로 판정한다 — '더보기'·검색 재렌더로 새로 그려질 때마다 필요하고,
|
||
// refreshSel을 호출하는 방식은 패널이 아직 document에 안 붙어 있으면(최초 구성 중)
|
||
// 조용히 no-op 되므로 쓰지 않는다.
|
||
const assigned=(selCut[hlId]||{})[c.idx];
|
||
if(sel[hlId]&&sel[hlId].includes(c.idx)&&(assigned===undefined||assigned===ci))
|
||
w.classList.add("sel");
|
||
grid.appendChild(w);
|
||
}
|
||
if(cur===list) baseShown=Math.max(baseShown,shown);
|
||
if(shown>=cur.length) more.style.display="none";
|
||
else{
|
||
more.style.display="";
|
||
more.textContent="더보기 ▾ (남은 "+(cur.length-shown).toLocaleString()+"장)";
|
||
}
|
||
applyUsedMarks(hlId); // 새로 그린 카드에도 사용중 표시
|
||
}
|
||
more.addEventListener("click",()=>render(CARD_PAGE));
|
||
sec.appendChild(more);
|
||
render(first);
|
||
(SECS[hlId]=SECS[hlId]||[]).push(function setFilter(q){
|
||
grid.innerHTML="";shown=0;
|
||
if(!q){cur=list;sec.style.display="";render(Math.max(baseShown,first));return;}
|
||
cur=list.filter(i=>{const c=byIdx[i];return c&&searchStr(c).includes(q);});
|
||
sec.style.display=cur.length?"":"none";
|
||
render(CARD_PAGE);
|
||
});
|
||
return sec;
|
||
}
|
||
/* 검색 중엔 닫힌 컷 안의 결과도 봐야 하므로 그 패널의 컷 아코디언을 전부 펼친다.
|
||
검색어를 지우면 검색 시작 전에 열려 있던 컷으로 되돌린다 — 그냥 첫 컷으로 리셋하면
|
||
사용자가 보던 컷을 잃어버린다. 컷 개념이 없는 폴백 패널은 .cutgrp 가 없어 자동 no-op. */
|
||
function syncCutGroupsForSearch(hlId,q){
|
||
const box=$("#hlbox-"+hlId); if(!box) return;
|
||
const grps=box.querySelectorAll(".cutgrp"); if(!grps.length) return;
|
||
if(q){
|
||
if(box.dataset.presearchOpen===undefined)
|
||
box.dataset.presearchOpen=JSON.stringify(Array.from(grps).map(g=>g.classList.contains("open")));
|
||
grps.forEach(g=>g.classList.add("open"));
|
||
}else if(box.dataset.presearchOpen!==undefined){
|
||
const opens=JSON.parse(box.dataset.presearchOpen);
|
||
grps.forEach((g,i)=>g.classList.toggle("open",!!opens[i]));
|
||
delete box.dataset.presearchOpen;
|
||
}
|
||
}
|
||
/* ── 패널 검색창 — 입력하면 그 패널의 모든 섹션이 일치 카드만 다시 그림 ── */
|
||
function searchBar(hlId){
|
||
SECS[hlId]=[]; // 패널 재생성 시 이전 섹션 필터 폐기
|
||
const bar=document.createElement("div");
|
||
bar.className="ccsearch";
|
||
bar.innerHTML='<input type="search" placeholder="🔍 댓글 검색 (내용·작성자)">'+
|
||
'<button type="button" class="ghost ccsx" title="검색 지우기">✕</button>';
|
||
const inp=bar.querySelector("input");
|
||
let t=null;
|
||
function apply(){
|
||
const q=inp.value.trim().toLowerCase();
|
||
syncCutGroupsForSearch(hlId,q);
|
||
(SECS[hlId]||[]).forEach(f=>f(q));
|
||
}
|
||
inp.addEventListener("input",()=>{clearTimeout(t);t=setTimeout(apply,150);});
|
||
bar.querySelector(".ccsx").addEventListener("click",()=>{inp.value="";apply();inp.focus();});
|
||
bar._clear=()=>{if(inp.value){inp.value="";clearTimeout(t);apply();}};
|
||
return bar;
|
||
}
|
||
/* 빌드/캡처 전 필수 — 검색으로 카드가 DOM에서 빠진 채 캡처하면 조용히 누락된다 */
|
||
function clearSearch(hlId){
|
||
const box=$("#hlbox-"+hlId);
|
||
const bar=box&&box.querySelector(".ccsearch");
|
||
if(bar&&bar._clear) bar._clear();
|
||
}
|
||
/* ── 컷 아코디언 — 컷마다 '추천 + 채우기' 두 섹션을 한 블록으로 접는다.
|
||
한 번에 하나만 펼쳐 화면에 후보가 다닥다닥 쌓이는 걸 막는다(카드는 컷 수만큼 DOM에
|
||
복제되므로 전부 펼치면 매우 길어진다). 헤더 채움 상태는 refreshSel 에서 갱신한다. */
|
||
function cutGroup(ci,title,sec,quota,isOpen){
|
||
const grp=document.createElement("div");
|
||
grp.className="cutgrp"+(isOpen?" open":"");
|
||
grp.dataset.ci=ci; grp.dataset.sec=sec; grp.dataset.quota=quota;
|
||
const head=document.createElement("button");
|
||
head.type="button"; head.className="cutgrp-head";
|
||
head.innerHTML='<span class="chev" aria-hidden="true">▶</span>'+
|
||
'<span class="cgtitle"></span><span class="cgstat"></span>';
|
||
head.querySelector(".cgtitle").textContent=title;
|
||
head.addEventListener("click",()=>{
|
||
const opening=!grp.classList.contains("open");
|
||
grp.parentElement.querySelectorAll(":scope > .cutgrp").forEach(g=>g.classList.remove("open"));
|
||
grp.classList.toggle("open",opening);
|
||
});
|
||
const body=document.createElement("div"); body.className="cutgrp-body";
|
||
grp.appendChild(head); grp.appendChild(body);
|
||
return {grp,body};
|
||
}
|
||
/* ── 카드 패널 렌더 — 자동/구간/붙여넣기 세 탭이 공유한다.
|
||
갈라 두면 한 곳만 고치는 실수가 난다(배지·검색·선택 상한이 전부 여기 모여 있다). ── */
|
||
function renderCutPanel(box,panelId,data,opts){
|
||
const unit=(opts&&opts.unit)||"컷";
|
||
const WHY={ts:"⭐",ai:"🤖",like:"➕"};
|
||
box.appendChild(searchBar(panelId)); // 카드 섹션들 위 — cardSection 보다 먼저 만들어야 SECS 초기화됨
|
||
const cand=(data.candidates||[]).filter(i=>byIdx[i]!==undefined);
|
||
if(data.cuts){
|
||
/* 컷별 추천 — 컷마다 두 섹션. 독립 ➕ 섹션을 두지 않는 이유: 거기서 고른 카드는
|
||
컷 소속(ci)이 없어 빌드 때 갈 곳이 없다. quota 미달분을 ➕ 에서 채우는 게 주 워크플로다.
|
||
rest(추천 뒤 '나머지')는 그 컷 시간대를 언급한 댓글만 — 안 그러면 컷마다 전체가 중복된다. */
|
||
for(const cu of data.cuts){
|
||
const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined);
|
||
const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join("");
|
||
const cs=(data.cutRanges||[])[cu.i]||{start:0,end:-1};
|
||
const rest=(data.matched||[]).filter(i=>
|
||
byIdx[i]!==undefined&&!rec.includes(i)&&
|
||
(byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end));
|
||
const {grp,body}=cutGroup(cu.i,unit+" "+(cu.i+1),cu.sec,cu.quota,cu.i===0);
|
||
body.appendChild(cardSection(
|
||
unit+" "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
|
||
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""),
|
||
rec.concat(rest),panelId,Math.max(CARD_PAGE,rec.length),cu.i));
|
||
// 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로
|
||
if(cand.length)
|
||
body.appendChild(cardSection(
|
||
"↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)",
|
||
cand,panelId,CUT_FILL_PAGE,cu.i));
|
||
box.appendChild(grp);
|
||
}
|
||
}else{
|
||
// 폴백 — cuts 정보가 없으면(구간 탭·구정보 없음) 예전 화면 그대로: ⭐ + 독립 ➕
|
||
// ⚠ 이 ⭐ 문구는 예전부터 "이 구간을"로 고정돼 있었다(unit="컷"이어도) — 화면 불변 원칙상 유지
|
||
const m=(data.matched||[]).filter(i=>byIdx[i]!==undefined);
|
||
box.appendChild(cardSection(
|
||
"⭐ "+(unit==="컷"?"이 구간":unit)+"을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
|
||
m,panelId,Math.max(CARD_PAGE,(sel[panelId]||[]).length)));
|
||
if(cand.length){
|
||
box.appendChild(cardSection(
|
||
"➕ 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
|
||
cand,panelId,CARD_PAGE));
|
||
}
|
||
}
|
||
}
|
||
/* ── 컷별 선택 ── 카드는 '어느 컷 소속'인지가 배치를 정한다.
|
||
selCut[hlId][idx] = 컷 인덱스. sel[hlId] 는 항상 컷 순서로 정렬해 둔다
|
||
(업로드 순서 = 배치 순서라서). */
|
||
const selCut={};
|
||
function sortSel(hlId){
|
||
const m=selCut[hlId]||{};
|
||
sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999));
|
||
}
|
||
function cutFull(hl,ci){
|
||
const m=selCut[hl.id]||{};
|
||
const q=(hl.cuts&&hl.cuts[ci])?hl.cuts[ci].quota:hl.need;
|
||
return sel[hl.id].filter(i=>m[i]===ci).length>=q;
|
||
}
|
||
function toggle(hlId,idx,ci){
|
||
const hl=hlById(hlId), list=sel[hlId];
|
||
selCut[hlId]=selCut[hlId]||{};
|
||
const at=list.indexOf(idx);
|
||
if(at>=0){
|
||
const cur=selCut[hlId][idx];
|
||
// 이미 다른 컷에 배정된 댓글의 쌍둥이 카드를 누르면 해제가 아니라 '그 컷으로 옮기기'다
|
||
// — 사용자는 이 댓글을 지우려는 게 아니라 지금 보고 있는 컷에 넣으려는 것.
|
||
if(hl.cuts&&ci!==undefined&&cur!==undefined&&cur!==ci){
|
||
if(cutFull(hl,ci)) return; // 옮길 컷이 이미 꽉 참 — 아무것도 안 함
|
||
selCut[hlId][idx]=ci;
|
||
sortSel(hlId); // 업로드 순서 = 컷 순서 유지
|
||
}else{
|
||
list.splice(at,1); delete selCut[hlId][idx];
|
||
}
|
||
}else{
|
||
if(hl.cuts&&ci!==undefined){
|
||
if(cutFull(hl,ci)) return; // 그 컷 장수 초과 방지
|
||
selCut[hlId][idx]=ci;
|
||
}else{
|
||
if(list.length>=hl.need) return; // 폴백(컷 정보 없음)
|
||
}
|
||
list.push(idx);
|
||
sortSel(hlId);
|
||
}
|
||
refreshSel(hlId);
|
||
}
|
||
/* 선택 상태를 카드 링·요약 칩·탭 카운터에 일괄 반영 */
|
||
function refreshSel(hlId){
|
||
const hl=hlById(hlId), list=sel[hlId];
|
||
const box=$("#hlbox-"+hlId); if(!box) return;
|
||
const m=selCut[hlId]||{};
|
||
// 컷별 채우기 섹션은 같은 댓글의 복사본을 컷마다 다시 그린다 — 링은 '배정된 컷'의
|
||
// 복사본에만 붙인다(배정이 없으면 폴백 패널이라 항상 붙인다). 그래야 1컷에서 고른 카드가
|
||
// 2컷 복사본에도 ✓로 보여 헷갈리는 일이 없다. 나머지 복사본은 applyUsedMarks 가 표시한다.
|
||
box.querySelectorAll(".ccwrap").forEach(w=>{
|
||
const idx=parseInt(w.dataset.cidx,10);
|
||
const assigned=m[idx];
|
||
const wCi=("ci" in w.dataset)?parseInt(w.dataset.ci,10):undefined;
|
||
const isThisCopy=(assigned===undefined)||(assigned===wCi);
|
||
w.classList.toggle("sel",list.includes(idx)&&isThisCopy);
|
||
});
|
||
// 컷 아코디언 헤더 채움 상태(선택 수/quota) — 선택이 바뀔 때마다 갱신
|
||
box.querySelectorAll(".cutgrp").forEach(g=>{
|
||
const ci=parseInt(g.dataset.ci,10);
|
||
const cnt=list.filter(i=>m[i]===ci).length;
|
||
const stat=g.querySelector(".cgstat");
|
||
if(stat) stat.textContent="· "+g.dataset.sec+"초 · "+cnt+"/"+g.dataset.quota+"장";
|
||
});
|
||
applyUsedMarks(hlId); // 크로스 ID 사용중 + 같은 ID 안 다른 컷 쌍둥이 표시(한 곳에서 관리)
|
||
// 탭 카운터
|
||
const tab=$("#idtab-"+hlId);
|
||
if(tab){
|
||
const ts=tab.querySelector(".ts");
|
||
const off=isDropped(hlId);
|
||
ts.textContent=off?"제외됨 · 생성 안 함":("카드 "+list.length+"/"+hl.need);
|
||
tab.classList.toggle("full",!off&&list.length>=hl.need);
|
||
}
|
||
// 헤더 카운터
|
||
const cnt=$("#hlcnt-"+hlId);
|
||
if(cnt) cnt.textContent=list.length+" / "+hl.need+"장 선택";
|
||
// 선택 요약 칩
|
||
const bar=$("#hlsel-"+hlId);
|
||
if(bar){
|
||
bar.innerHTML='<span class="selbar-l">✔ 선택한 댓글</span>';
|
||
list.forEach((idx,i)=>{
|
||
const c=byIdx[idx]; if(!c) return;
|
||
const chip=document.createElement("span");
|
||
chip.className="selchip";
|
||
chip.innerHTML='<span class="no">'+(i+1)+'</span><span class="nm"></span>'+
|
||
'<span class="x" title="선택 해제">✕</span>';
|
||
chip.querySelector(".nm").textContent=
|
||
(c.authorName||"익명")+" · 👍"+(c.likeCount||0).toLocaleString();
|
||
chip.title=String(c.text||"").slice(0,120);
|
||
chip.addEventListener("click",(e)=>{ // 칩 클릭 = 카드로 스크롤
|
||
if(e.target.classList.contains("x")) return;
|
||
const ci=(selCut[hlId]||{})[idx];
|
||
if(ci!==undefined){ // 카드가 속한 컷 아코디언을 펼쳐야 스크롤 결과가 보인다
|
||
box.querySelectorAll(".cutgrp").forEach(g=>
|
||
g.classList.toggle("open",parseInt(g.dataset.ci,10)===ci));
|
||
}
|
||
const w=wrapOf(hlId,idx,ci); // ci를 넘겨 배정된(=보이는) 복사본을 정확히 찾는다
|
||
if(w) w.scrollIntoView({behavior:"smooth",block:"center"});
|
||
});
|
||
chip.querySelector(".x").addEventListener("click",()=>toggle(hlId,idx));
|
||
bar.appendChild(chip);
|
||
});
|
||
if(!list.length){
|
||
const em=document.createElement("span");
|
||
em.className="selbar-l"; em.textContent="없음 — 아래 카드를 클릭해 선택";
|
||
bar.appendChild(em);
|
||
}
|
||
// 선택만 보기 토글(유지)
|
||
const lb=document.createElement("label");
|
||
lb.className="selonly-label";
|
||
const on=box.classList.contains("selonly");
|
||
lb.innerHTML='<input type="checkbox" '+(on?"checked ":"")+
|
||
'style="accent-color:var(--accent);width:14px;height:14px;"> 선택한 것만 보기';
|
||
lb.querySelector("input").addEventListener("change",(e)=>
|
||
box.classList.toggle("selonly",e.target.checked));
|
||
bar.appendChild(lb);
|
||
}
|
||
}
|
||
|
||
/* ── 다른 ID에서 이미 선택한 댓글 표시 ── */
|
||
function usedByOthers(hlId,idx){
|
||
const ids=[];
|
||
for(const k in sel){
|
||
if(String(k)===String(hlId)) continue;
|
||
if(isDropped(k)) continue; // 제외된 ID는 '사용중'으로 치지 않음
|
||
if(sel[k]&&sel[k].includes(idx)) ids.push(k);
|
||
}
|
||
return ids;
|
||
}
|
||
function applyUsedMarks(hlId){
|
||
const box=$("#hlbox-"+hlId); if(!box) return;
|
||
const hl=hlById(hlId);
|
||
const m=(hl&&hl.cuts)?(selCut[hlId]||{}):null; // 컷 개념이 있는 패널만 쌍둥이 체크
|
||
box.querySelectorAll(".ccwrap").forEach(w=>{
|
||
const idx=parseInt(w.dataset.cidx,10);
|
||
let text=null;
|
||
// ① 같은 ID 안에서, 이 댓글이 실제로 배정된 컷과 다른 컷의 복사본 — '이동' 대상임을 알려준다
|
||
if(m){
|
||
const assigned=m[idx];
|
||
const wCi=("ci" in w.dataset)?parseInt(w.dataset.ci,10):undefined;
|
||
if(assigned!==undefined&&wCi!==undefined&&assigned!==wCi)
|
||
text="컷 "+(assigned+1)+"에서 사용중";
|
||
}
|
||
// ② 다른 ID(탭)에서 이미 선택한 댓글 — 기존 표시(문구 구분 유지)
|
||
if(!text){
|
||
const ids=usedByOthers(hlId,idx);
|
||
if(ids.length) text="ID "+ids.join("·")+" 사용중";
|
||
}
|
||
w.classList.toggle("used",!!text);
|
||
let b=w.querySelector(".ccused");
|
||
if(text){
|
||
if(!b){b=document.createElement("div");b.className="ccused";w.appendChild(b);}
|
||
b.textContent=text;
|
||
w.title=text;
|
||
}else{
|
||
if(b) b.remove();
|
||
w.removeAttribute("title");
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ── 댓글 카드 영역 접기/펼치기 ── */
|
||
function setFolded(on){
|
||
const R=$("#autoReview");if(!R)return;
|
||
R.classList.toggle("folded",on);
|
||
const b=$("#ccFoldBtn");
|
||
if(b){
|
||
b.querySelector(".tt").textContent=on?"▾ 댓글 펼치기":"▴ 댓글 접기";
|
||
b.querySelector(".ts").textContent=on?"카드 숨김 상태":"카드 표시 상태";
|
||
}
|
||
}
|
||
|
||
/* ── ID 탭 전환 ── */
|
||
function showId(hlId){
|
||
if(isDropped(hlId)) return; // 제외된 ID는 열지 않음
|
||
curId=hlId;
|
||
document.querySelectorAll(".hlbox:not(.errbox)").forEach(b=>
|
||
b.classList.toggle("show",b.id==="hlbox-"+hlId));
|
||
document.querySelectorAll(".idtab").forEach(t=>
|
||
t.classList.toggle("active",t.id==="idtab-"+hlId));
|
||
applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신
|
||
}
|
||
|
||
/* ── ID 제외 / 되돌리기 ──
|
||
지우지 않고 '제외' 상태로만 둔다 → 실수로 눌러도 한 번에 복구 가능(되돌리기 지원).
|
||
제외된 ID는 빌드에서 빠지고, 그 ID가 잡고 있던 댓글도 '사용중' 표시에서 풀린다. */
|
||
function liveIds(){
|
||
if(!A) return [];
|
||
return A.highlights.filter(h=>!h.error&&!isDropped(h.id)).map(h=>h.id);
|
||
}
|
||
function updateBuildBtn(){
|
||
const btn=$("#autoBuild"); if(!btn||!A) return;
|
||
const n=liveIds().length;
|
||
btn.disabled=(n===0);
|
||
btn.textContent=n?(n+"개 전부 만들기"):"만들 ID가 없습니다 — 제외를 해제하세요";
|
||
}
|
||
function setDropped(hlId,on){
|
||
const key=String(hlId);
|
||
if(on) dropped.add(key); else dropped.delete(key);
|
||
const tab=$("#idtab-"+hlId);
|
||
if(tab) tab.classList.toggle("dropped",on);
|
||
const x=$("#xdel-"+hlId);
|
||
if(x){
|
||
x.textContent=on?"↺":"✕";
|
||
x.title=on?"다시 포함":"이 ID 제외 (생성 안 함)";
|
||
x.setAttribute("aria-label","ID "+hlId+(on?" 다시 포함":" 제외 — 생성하지 않음"));
|
||
x.setAttribute("aria-pressed",on?"true":"false");
|
||
}
|
||
const box=$("#hlbox-"+hlId);
|
||
if(box&&on) box.classList.remove("show");
|
||
if(on&&String(curId)===key){ // 보던 탭을 제외 → 남은 첫 탭으로 이동
|
||
const nxt=liveIds()[0];
|
||
if(nxt!=null) showId(nxt); else curId=null;
|
||
}
|
||
if(!on) showId(hlId); // 되돌리면 그 탭을 연다
|
||
refreshSel(hlId);
|
||
if(curId!=null) applyUsedMarks(curId);
|
||
updateBuildBtn();
|
||
}
|
||
|
||
/* ── 영상 편집안(JSON 컷) — 기본 접힘 ──
|
||
hl.paste.cuts = [{start, end, bottom, effect}] (start/end 는 원본 영상 기준 초). */
|
||
function cutsSection(hl){
|
||
const cuts=(hl.paste&&hl.paste.cuts)||[];
|
||
const d=document.createElement("details");
|
||
d.className="cutsbox";
|
||
const sum=document.createElement("summary");
|
||
sum.innerHTML='<span class="chev" aria-hidden="true">▶</span>'+
|
||
'<span class="ctitle">영상 편집안</span>'+
|
||
'<span class="cprev"></span>'+
|
||
'<span class="cnum tnum">컷 '+cuts.length+"개 · "+(hl.total!=null?hl.total:0)+"초</span>";
|
||
const first=cuts.map(c=>(c.bottom||"").split("\n")[0].trim()).filter(Boolean)[0]||"";
|
||
sum.querySelector(".cprev").textContent=first?("— "+first):"";
|
||
d.appendChild(sum);
|
||
|
||
const list=document.createElement("div");
|
||
list.className="cutlist";
|
||
cuts.forEach((c,i)=>{
|
||
const row=document.createElement("div");
|
||
row.className="cutrow";
|
||
const dur=Math.max(0,(c.end||0)-(c.start||0));
|
||
row.innerHTML='<span class="cno">'+(i+1)+"</span>"+
|
||
'<span class="ctime">'+fmtT(c.start||0)+"~"+fmtT(c.end||0)+
|
||
'<span class="cdur">'+dur.toFixed(1)+"초</span></span>"+
|
||
'<span class="cbody"><span class="cbot"></span><span class="ceff"></span></span>';
|
||
const bot=String(c.bottom||"").trim();
|
||
const bEl=row.querySelector(".cbot");
|
||
bEl.textContent=bot||"(자막 없음)";
|
||
if(!bot) bEl.classList.add("cempty");
|
||
const eff=String(c.effect||"").trim();
|
||
if(eff) row.querySelector(".ceff").textContent=eff;
|
||
else row.querySelector(".ceff").remove();
|
||
list.appendChild(row);
|
||
});
|
||
if(!cuts.length){
|
||
const em=document.createElement("div");
|
||
em.className="cutrow";em.style.gridTemplateColumns="1fr";
|
||
em.textContent="컷이 없습니다.";
|
||
list.appendChild(em);
|
||
}
|
||
d.appendChild(list);
|
||
|
||
// JSON 원문(더 깊이 접어둠) + 복사
|
||
const raw=document.createElement("details");
|
||
raw.className="cutraw";
|
||
const rs=document.createElement("summary");
|
||
rs.textContent="{ } JSON 원문";
|
||
raw.appendChild(rs);
|
||
const pre=document.createElement("pre");
|
||
pre.textContent=JSON.stringify(hl.paste,null,1);
|
||
raw.appendChild(pre);
|
||
const cp=document.createElement("button");
|
||
cp.type="button";cp.className="ghost cutcopy";cp.textContent="JSON 복사";
|
||
cp.addEventListener("click",async()=>{
|
||
try{await navigator.clipboard.writeText(pre.textContent);cp.textContent="복사됨";}
|
||
catch(e){cp.textContent="복사 실패";}
|
||
setTimeout(()=>{cp.textContent="JSON 복사";},1200);
|
||
});
|
||
raw.appendChild(cp);
|
||
d.appendChild(raw);
|
||
return d;
|
||
}
|
||
|
||
/* ── 방식 선택 ── */
|
||
function curMode(){
|
||
const r=document.querySelector('input[name="amode"]:checked');
|
||
return r?r.value:"full";
|
||
}
|
||
const MODE_HELP={
|
||
full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다.",
|
||
whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 무음 제거·자막은 아래 공통 옵션을 따릅니다.",
|
||
wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. 제목(윗줄·아랫줄)은 댓글 선택 화면에서 ID별로 직접 씁니다.",
|
||
paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다.",
|
||
};
|
||
const PASTE_UI={
|
||
paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)",
|
||
ph:'오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요.\n블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.',
|
||
note:"(선택 — 비우면 JSON의 url 사용)"},
|
||
wpaste:{label:"구간 JSON 붙여넣기 (candidates 배열 — 구간 5개)",
|
||
ph:'{\n "candidates": [\n {"id": 1, "start_time": "14:28", "end_time": "16:15", "reason": "구간 선정 이유"},\n … (총 5개)\n ]\n}',
|
||
note:"(필수 — 구간 JSON에는 URL이 없습니다)"},
|
||
};
|
||
function applyMode(){
|
||
const m=curMode();
|
||
const p=PASTE_UI[m];
|
||
$("#apasteField").style.display=p?"block":"none";
|
||
if(p){
|
||
$("#apasteLabel").textContent=p.label;
|
||
$("#apaste").placeholder=p.ph;
|
||
}
|
||
$("#autoUrlNote").textContent=p?p.note:"";
|
||
$("#autoModeHelp").textContent=MODE_HELP[m];
|
||
$("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)";
|
||
}
|
||
|
||
/* ── 분석 ── */
|
||
async function analyze(){
|
||
const m=curMode();
|
||
const url=$("#autoUrl").value.trim();
|
||
if(m!=="paste"&&!url){
|
||
alert(m==="wpaste"?"유튜브 URL을 입력하세요. (구간 JSON에는 URL이 없습니다)"
|
||
:"유튜브 URL을 입력하세요.");return;}
|
||
if(PASTE_UI[m]&&!$("#apaste").value.trim()){
|
||
alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;}
|
||
$("#autoGo").disabled=true;$("#autoGo").textContent="분석 중…";
|
||
$("#autoReview").innerHTML="";$("#autoSummary").style.display="none";
|
||
$("#autoBuild").style.display="none";$("#autoLog").innerHTML="";
|
||
$("#buildBoard").style.display="none";$("#buildBoard").innerHTML="";
|
||
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="none";
|
||
let res;
|
||
try{
|
||
const fd=new FormData();
|
||
fd.append("url",url);
|
||
fd.append("mode",m);
|
||
if(PASTE_UI[m]) fd.append("data",$("#apaste").value);
|
||
res=await(await fetch("/auto/analyze",{method:"POST",body:fd})).json();
|
||
}catch(e){return failA("요청 실패: "+e);}
|
||
if(res.error) return failA(res.error);
|
||
const es=new EventSource("/auto/stream/"+res.analysis_id);
|
||
es.onmessage=(m)=>{
|
||
const ev=JSON.parse(m.data);
|
||
if(ev.type==="manifest") renderASteps(ev.steps);
|
||
else if(ev.type==="step") updateAStep(ev);
|
||
else if(ev.type==="log") alog(ev.msg);
|
||
else if(ev.type==="result"){es.close();onResult(ev);doneA();}
|
||
else if(ev.type==="error"){es.close();failA(ev.message);}
|
||
};
|
||
es.onerror=()=>{es.close();failA("연결이 끊겼습니다.");};
|
||
}
|
||
function doneA(){$("#autoGo").disabled=false;applyMode();}
|
||
function failA(m){alog("⚠️ "+m);doneA();}
|
||
function alog(m){
|
||
const d=document.createElement("div");
|
||
d.style.cssText="font-family:var(--mono);font-size:12px;color:var(--muted2);margin-top:4px;white-space:pre-wrap;";
|
||
d.textContent=m;$("#autoLog").appendChild(d);
|
||
}
|
||
function renderASteps(steps){
|
||
$("#autoSteps").innerHTML=steps.map(s=>
|
||
'<div class="step" id="ast-'+s.id+'"><span class="sdot"></span>'+
|
||
'<div class="slabel"><div class="t">'+esc(s.label)+'</div><div class="sdetail" hidden></div></div>'+
|
||
'<div class="selapsed"></div></div>').join("");
|
||
$("#autoSteps").style.display="block";
|
||
}
|
||
function updateAStep(ev){
|
||
const el=$("#ast-"+ev.id);if(!el)return;
|
||
if(ev.status==="start"){el.classList.add("active");}
|
||
else if(ev.status==="done"){el.classList.remove("active");el.classList.add("done");
|
||
if(ev.detail){const d=el.querySelector(".sdetail");d.hidden=false;d.textContent=ev.detail;}}
|
||
}
|
||
|
||
/* ── 검토 화면 (ID별 탭) ── */
|
||
function onResult(ev){
|
||
A=ev;byIdx={};sel={};curId=null;dropped=new Set();
|
||
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
|
||
(ev.warnings||[]).forEach(w=>alog("⚠️ "+w));
|
||
const R=$("#autoReview");R.innerHTML="";
|
||
const tabs=document.createElement("div");tabs.className="idtabs";tabs.id="idTabs";
|
||
R.appendChild(tabs);
|
||
let firstOk=null,ok=0;
|
||
for(const hl of ev.highlights){
|
||
// 탭 버튼
|
||
const tab=document.createElement("button");
|
||
tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id;
|
||
if(hl.error){
|
||
tab.classList.add("err");
|
||
tab.innerHTML='<span class="tt">ID '+hl.id+' ✗</span><span class="ts">생성 실패 (클릭=사유)</span>';
|
||
tab.title=hl.error;
|
||
tab.addEventListener("click",()=>alog("ID "+hl.id+" 실패 사유: "+hl.error));
|
||
tabs.appendChild(tab);
|
||
continue;
|
||
}
|
||
ok++; if(firstOk===null) firstOk=hl.id;
|
||
tab.innerHTML='<span class="tt">ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+"</span>"+
|
||
'<span class="ts"></span>';
|
||
tab.addEventListener("click",()=>{ // 제외된 탭은 클릭만으로도 되돌리기
|
||
if(isDropped(hl.id)) setDropped(hl.id,false); else showId(hl.id);
|
||
});
|
||
// ✕ 는 탭 버튼의 '형제' (button 중첩 불가) — 래퍼로 감싸 겹쳐 놓는다
|
||
const wrap=document.createElement("div");
|
||
wrap.className="idtabwrap";
|
||
const x=document.createElement("button");
|
||
x.type="button";x.className="xdel";x.id="xdel-"+hl.id;x.textContent="✕";
|
||
x.title="이 ID 제외 (생성 안 함)";
|
||
x.setAttribute("aria-label","ID "+hl.id+" 제외 — 생성하지 않음");
|
||
x.setAttribute("aria-pressed","false");
|
||
x.addEventListener("click",(e)=>{
|
||
e.stopPropagation();
|
||
setDropped(hl.id,!isDropped(hl.id));
|
||
});
|
||
wrap.appendChild(tab);wrap.appendChild(x);
|
||
tabs.appendChild(wrap);
|
||
|
||
// 패널
|
||
const box=document.createElement("div");
|
||
box.className="hlbox";box.id="hlbox-"+hl.id;
|
||
// 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백.
|
||
sel[hl.id]=[]; selCut[hl.id]={};
|
||
if(hl.cuts){
|
||
for(const cu of hl.cuts)
|
||
for(const p of (cu.picks||[]))
|
||
if(byIdx[p.idx]!==undefined&&!sel[hl.id].includes(p.idx)){
|
||
sel[hl.id].push(p.idx); selCut[hl.id][p.idx]=cu.i;
|
||
}
|
||
}else{
|
||
sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
|
||
}
|
||
const cuts=hl.paste.cuts;
|
||
let html="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
|
||
" <span style='color:var(--muted2);font-weight:400;'>"+esc(hl.reason||"")+"</span></h3>"+
|
||
'<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+
|
||
'<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>';
|
||
let opts=[];
|
||
if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이)
|
||
html+='<div class="hlsec">제목: '+
|
||
'<input class="hlinput" id="hltop-'+hl.id+'" placeholder="윗줄 · 주황 (선택)"> '+
|
||
'<input class="hlinput" id="hlmain-'+hl.id+'" placeholder="아랫줄 · 흰색 (선택)"></div>';
|
||
}else{
|
||
const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"};
|
||
opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main));
|
||
html+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
|
||
opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
|
||
(t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
|
||
}
|
||
html+='<div class="selbar" id="hlsel-'+hl.id+'"></div>';
|
||
box.innerHTML=html;
|
||
box.dataset.titles=JSON.stringify(opts);
|
||
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
|
||
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
|
||
renderCutPanel(box,hl.id,
|
||
{cuts:hl.cuts,matched:hl.matched,candidates:hl.candidates,
|
||
cutRanges:hl.paste.cuts},{unit:"컷"});
|
||
R.appendChild(box);
|
||
refreshSel(hl.id);
|
||
}
|
||
// 접기/펼치기 버튼 — 탭 바 오른쪽 끝
|
||
if(ok){
|
||
const fb=document.createElement("button");
|
||
fb.type="button";fb.className="idtab ccfoldbtn";fb.id="ccFoldBtn";
|
||
fb.innerHTML='<span class="tt">▴ 댓글 접기</span><span class="ts">카드 표시 상태</span>';
|
||
fb.addEventListener("click",()=>setFolded(!$("#autoReview").classList.contains("folded")));
|
||
tabs.appendChild(fb);
|
||
}
|
||
if(firstOk!==null) showId(firstOk);
|
||
if(ok){
|
||
$("#autoBuild").style.display="block";
|
||
updateBuildBtn();
|
||
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex";
|
||
}
|
||
}
|
||
|
||
/* ── 캡처 + 순차 빌드 ── */
|
||
async function captureCard(wrap){
|
||
const img=wrap.querySelector(".cc-avatar");
|
||
if(img&&img.src&&!img.complete){ // 숨겨진 탭의 lazy 아바타 → 강제 로드 후 캡처
|
||
img.loading="eager";
|
||
try{await img.decode();}catch(e){}
|
||
}
|
||
const card=wrap.querySelector(".comment-card");
|
||
return await window.modernScreenshot.domToBlob(card,{backgroundColor:null,scale:4});
|
||
}
|
||
const nextFrame=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
|
||
|
||
/* 빌드/캡처 전 필수(검색 초기화와 같은 이유) — 접힌 컷 섹션은 display:none 이라
|
||
레이아웃이 확정되지 않아 그 안의 카드를 domToBlob 으로 찍으면 빈 이미지가 나온다.
|
||
캡처는 wrapOf() 가 DOM에서 '첫 번째로 찾은' 복사본을 찍으므로, 그 복사본이 하필
|
||
접힌 컷 섹션 안에 있으면 조용히 깨진다 — 다음 사람이 "펼칠 필요 없는데?" 하고
|
||
지우면 이 버그가 다시 살아난다. 지우지 말 것. */
|
||
function expandAllCuts(hlId){
|
||
const box=$("#hlbox-"+hlId); if(!box) return;
|
||
box.querySelectorAll(".cutgrp").forEach(g=>g.classList.add("open"));
|
||
}
|
||
/* 빌드 진행판 — 버튼 아래 ID별 한 줄씩, 전체 상황이 한눈에 보임 */
|
||
function boardInit(hls){
|
||
const bd=$("#buildBoard");
|
||
bd.innerHTML=hls.map(hl=>
|
||
'<div class="bbrow" id="bb-'+hl.id+'">'+
|
||
'<span class="bbid">ID '+hl.id+'</span>'+
|
||
'<span class="bbstat">⏳ 대기</span>'+
|
||
'<span class="bbmsg"></span></div>').join("");
|
||
bd.style.display="block";
|
||
}
|
||
function boardSet(hlId,stat,msg,cls){
|
||
const row=$("#bb-"+hlId); if(!row) return;
|
||
row.className="bbrow"+(cls?" "+cls:"");
|
||
if(stat!=null) row.querySelector(".bbstat").textContent=stat;
|
||
if(msg!=null) row.querySelector(".bbmsg").textContent=msg;
|
||
}
|
||
|
||
async function buildAll(){
|
||
const btn=$("#autoBuild");
|
||
const hls=A.highlights.filter(h=>!h.error&&!isDropped(h.id)); // 제외한 ID는 안 만듦
|
||
if(!hls.length){alert("만들 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;}
|
||
btn.disabled=true;
|
||
setFolded(true); // 빌드 시작 → 댓글 영역 접기(진행판에 집중)
|
||
boardInit(hls);
|
||
$("#buildBoard").scrollIntoView({behavior:"smooth",block:"nearest"});
|
||
let ok=0,fail=0;
|
||
for(const hl of hls){
|
||
showId(hl.id); // 캡처는 보이는 상태에서
|
||
clearSearch(hl.id); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
|
||
expandAllCuts(hl.id); // 접힌 컷 섹션의 카드는 빈 이미지로 캡처된다 — 전부 펼침
|
||
// '선택한 것만 보기'가 켜진 채면 배정 안 된 쌍둥이가 display:none으로 남는다.
|
||
// wrapOf(ci) 로 정확한 복사본을 찾도록 고쳤지만, 실패는 조용해서(빈 이미지·캡처 스킵)
|
||
// 사용자가 CapCut에서 드래프트를 열기 전엔 모른다 — 이중 방어로 아예 꺼 버린다.
|
||
const buildBox=$("#hlbox-"+hl.id);
|
||
if(buildBox) buildBox.classList.remove("selonly");
|
||
await nextFrame();
|
||
boardSet(hl.id,"🔄 진행","", "active");
|
||
try{
|
||
let pick;
|
||
if(hl.editable_title){
|
||
pick={top:($("#hltop-"+hl.id)?$("#hltop-"+hl.id).value.trim():""),
|
||
main:($("#hlmain-"+hl.id)?$("#hlmain-"+hl.id).value.trim():"")};
|
||
}else{
|
||
const opts=JSON.parse($("#hlbox-"+hl.id).dataset.titles);
|
||
pick=opts[parseInt($("#hlt-"+hl.id).value,10)||0];
|
||
}
|
||
const paste={...hl.paste,title_top:pick.top,title_main:pick.main};
|
||
const fd=new FormData();
|
||
fd.append("data",JSON.stringify(paste));
|
||
fd.append("tag","하이라이트"+hl.id);
|
||
fd.append("video_scale",$("#vscale").value||"144");
|
||
fd.append("flip",$("#flip").checked?"1":"0");
|
||
fd.append("scene",$("#scene").checked?"1":"0");
|
||
fd.append("bg_white",$("#bgwhite").checked?"1":"0");
|
||
fd.append("remove_silence",$("#rmsilence").checked?"1":"0");
|
||
fd.append("asr_bottom",$("#asrbottom").checked?"1":"0");
|
||
fd.append("cards_fixed",($("#autoCardsFixed")&&$("#autoCardsFixed").checked)?"1":"0");
|
||
const cmap=selCut[hl.id]||{};
|
||
let n=0; const sentCuts=[];
|
||
for(const idx of sel[hl.id]){
|
||
const ci=cmap[idx];
|
||
// ci를 반드시 wrapOf에 넘겨야 한다 — 안 넘기면 DOM에서 '처음 만나는' 복사본을
|
||
// 집는데, 컷 그룹은 컷 순서로 붙으므로 예를 들어 컷3에 배정된 댓글이라도 컷1의
|
||
// 채우기 섹션 복사본이 먼저 잡힐 수 있다(그 복사본은 배정 컷이 아니라 링도 없다).
|
||
const w=wrapOf(hl.id,idx,ci);
|
||
if(!w) continue;
|
||
/* 컷 소속을 모르는 카드는 통째로 건너뛴다. 예전엔 마지막 컷으로 폴백했는데,
|
||
_cards_by_cut 은 컷 '안에' 균등 분할하므로 그러면 4초짜리 마지막 컷에
|
||
여러 장이 몰려 1초 미만으로 번쩍인다. 이제 모든 카드가 ci 를 갖지만 방어로 남긴다. */
|
||
if(hl.cuts&&ci===undefined) continue;
|
||
boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
|
||
try{
|
||
const blob=await captureCard(w);
|
||
// ⚠ append 와 push 는 반드시 쌍으로 — 하나만 돌면 카드가 통째로 엉뚱한 컷에 붙는다
|
||
fd.append("cards",blob,String(n).padStart(3,"0")+".png");
|
||
sentCuts.push(ci);
|
||
}catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
|
||
}
|
||
if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));
|
||
boardSet(hl.id,null,"빌드 요청 중…","active");
|
||
const res=await(await fetch("/auto/build",{method:"POST",body:fd})).json();
|
||
if(res.error) throw new Error(res.error);
|
||
const r=await streamJob(res.job_id,hl.id); // 완료/실패까지 대기(순차)
|
||
boardSet(hl.id,"✅ 완료",(r&&r.draft_name)?r.draft_name:"","");
|
||
ok++;
|
||
const tab=$("#idtab-"+hl.id);
|
||
if(tab) tab.querySelector(".ts").textContent="✅ 완료";
|
||
}catch(e){
|
||
boardSet(hl.id,"❌ 실패",String(e.message||e),"err");fail++;
|
||
const tab=$("#idtab-"+hl.id);
|
||
if(tab) tab.querySelector(".ts").textContent="❌ 실패";
|
||
}
|
||
}
|
||
btn.disabled=false;btn.textContent="다시 만들기";
|
||
const s=$("#autoSummary");s.style.display="block";
|
||
s.textContent=ok+"개 성공"+(fail?", "+fail+"개 실패":"")+
|
||
" — CapCut 프로젝트 목록에서 드래프트를 여세요.";
|
||
if(ok&&$("#autoopen")&&$("#autoopen").checked)
|
||
fetch("/open-capcut",{method:"POST"});
|
||
}
|
||
function streamJob(jobId,hlId){
|
||
return new Promise((resolve,reject)=>{
|
||
const es=new EventSource("/stream/"+jobId);
|
||
es.onmessage=(m)=>{
|
||
const ev=JSON.parse(m.data);
|
||
if(ev.type==="log") boardSet(hlId,null,ev.msg,"active");
|
||
else if(ev.type==="step"&&ev.status==="start") boardSet(hlId,null,"▶ "+ev.id,"active");
|
||
else if(ev.type==="step"&&ev.status==="done"&&ev.detail)
|
||
boardSet(hlId,null,"✓ "+ev.id+" — "+ev.detail,"active");
|
||
else if(ev.type==="result"){es.close();resolve(ev);}
|
||
else if(ev.type==="error"){es.close();reject(new Error(ev.message));}
|
||
};
|
||
es.onerror=()=>{es.close();reject(new Error("연결 끊김"));};
|
||
});
|
||
}
|
||
|
||
/* ── 유튜브 구간 탭: 댓글 매칭 (자동 탭과 동일 UI) ── */
|
||
const YT_OFF=1000000; // 자동 탭 byIdx 와 충돌하지 않게 idx 오프셋
|
||
function ytSec(v){
|
||
v=(v||"").trim(); if(!v) return null;
|
||
const p=v.split(":").map(Number);
|
||
if(p.some(isNaN)) return null;
|
||
return p.length===3?p[0]*3600+p[1]*60+p[2]:p.length===2?p[0]*60+p[1]:p[0];
|
||
}
|
||
function ytRanges(){
|
||
const out=[];
|
||
document.querySelectorAll("#ranges .rng").forEach(row=>{
|
||
const s=ytSec(row.querySelector(".rstart").value);
|
||
if(s==null) return;
|
||
let e=ytSec(row.querySelector(".rend").value);
|
||
if(e==null) e=s+90; // 끝 비면 시작+1:30 (실행 로직과 동일)
|
||
if(e>s) out.push([s,e]);
|
||
});
|
||
return out;
|
||
}
|
||
async function ytMatch(){
|
||
const url=($("#yurl").value||"").trim();
|
||
if(!url){alert("유튜브 URL을 입력하세요.");return;}
|
||
const rng=ytRanges();
|
||
if(!rng.length){alert("구간을 하나 이상 입력하세요.");return;}
|
||
const btn=$("#ytccBtn");btn.disabled=true;btn.textContent="댓글 수집 중… (h-lab)";
|
||
let r;
|
||
try{
|
||
const fd=new FormData();
|
||
fd.append("url",url);fd.append("ranges",JSON.stringify(rng));
|
||
r=await(await fetch("/yt/comments",{method:"POST",body:fd})).json();
|
||
}catch(e){r={error:"요청 실패: "+e};}
|
||
btn.disabled=false;btn.textContent="💬 구간 댓글 다시 매칭";
|
||
if(r.error){$("#ytccNote").textContent="⚠ "+r.error;return;}
|
||
// idx 오프셋 후 등록 (자동 탭 상태와 공존)
|
||
(r.comments||[]).forEach(c=>{c.idx+=YT_OFF;byIdx[c.idx]=c;});
|
||
const matched=(r.matched||[]).map(i=>i+YT_OFF).filter(i=>byIdx[i]);
|
||
const cand=(r.candidates||[]).map(i=>i+YT_OFF).filter(i=>byIdx[i]);
|
||
YT_HL={id:"yt",need:r.need};
|
||
sel["yt"]=matched.slice(0,r.need);
|
||
const area=$("#ytccArea");area.innerHTML="";
|
||
const box=document.createElement("div");
|
||
box.className="hlbox show";box.id="hlbox-yt";
|
||
box.innerHTML="<h3>💬 구간 댓글</h3>"+
|
||
'<div class="hlmeta">구간 '+rng.length+"개 · 총 "+r.total+"초 · 카드 "+r.need+"장 필요 · "+
|
||
'<span id="hlcnt-yt" class="tnum"></span></div>'+
|
||
'<label class="selonly-label" style="margin-left:0;padding:2px 0;">'+
|
||
'<input type="checkbox" id="ytccFixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+
|
||
' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+
|
||
'<div class="selbar" id="hlsel-yt"></div>';
|
||
renderCutPanel(box,"yt",
|
||
{matched:matched,candidates:cand},{unit:"구간"});
|
||
area.appendChild(box);
|
||
refreshSel("yt");
|
||
$("#ytccNote").textContent="선택한 카드는 편집 시작 때 자동으로 들어갑니다(폴더 지정보다 우선). "
|
||
+"매칭 "+matched.length+"장 · 후보 "+cand.length+"장 · 댓글 "+(r.comments||[]).length+"개";
|
||
}
|
||
/* 편집 시작(index.html 인라인 스크립트)에서 쓰는 훅 */
|
||
window.ytCC={
|
||
active:function(){return !!(YT_HL&&sel["yt"]&&sel["yt"].length);},
|
||
capture:async function(){
|
||
clearSearch("yt"); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
|
||
const out=[];
|
||
for(const idx of sel["yt"]){
|
||
const w=wrapOf("yt",idx);
|
||
if(!w) continue;
|
||
try{out.push(await captureCard(w));}catch(e){/* 실패 카드는 건너뜀 */}
|
||
}
|
||
return out;
|
||
},
|
||
};
|
||
|
||
/* ── ⚙ 지침·모델 설정 ── */
|
||
async function loadPrompts(){
|
||
try{
|
||
const r=await(await fetch("/prompts")).json();
|
||
if(r.error){$("#pMsg").textContent="⚠ "+r.error;return;}
|
||
$("#pStep1").value=r.step1;$("#pStep3").value=r.step3;
|
||
$("#pConfig").value=JSON.stringify(r.config,null,1);
|
||
}catch(e){$("#pMsg").textContent="⚠ 불러오기 실패";}
|
||
}
|
||
async function savePrompts(reset){
|
||
const fd=new FormData();
|
||
if(reset) fd.append("reset","1");
|
||
else{
|
||
fd.append("step1",$("#pStep1").value);
|
||
fd.append("step3",$("#pStep3").value);
|
||
fd.append("config",$("#pConfig").value);
|
||
}
|
||
try{
|
||
const r=await(await fetch("/prompts",{method:"POST",body:fd})).json();
|
||
$("#pMsg").textContent=r.error?("⚠ "+r.error):"✓ 저장됨 (다음 분석부터 적용)";
|
||
if(!r.error&&reset) loadPrompts();
|
||
}catch(e){$("#pMsg").textContent="⚠ 저장 실패";}
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded",()=>{
|
||
$("#autoGo").addEventListener("click",analyze);
|
||
$("#autoUrl").addEventListener("keydown",(e)=>{if(e.key==="Enter")analyze();});
|
||
document.querySelectorAll('input[name="amode"]').forEach(r=>
|
||
r.addEventListener("change",applyMode));
|
||
applyMode();
|
||
$("#autoBuild").addEventListener("click",buildAll);
|
||
$("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();});
|
||
$("#pSave").addEventListener("click",()=>savePrompts(false));
|
||
$("#pReset").addEventListener("click",()=>savePrompts(true));
|
||
if($("#ytccBtn")) $("#ytccBtn").addEventListener("click",ytMatch);
|
||
});
|
||
})();
|