capcut-agent/server/static/auto.js
hehihoho3@gmail.com 011c042778 자동 탭: 🔤 단어겹침 배지 표시 + AI 추천 실패 경고 문구 명확화
WHY 배지 맵에 word:"🔤" 를 더해 3순위(단어 겹침)로 배정된 카드가 화면에서
구분되게 했다(다른 곳엔 why 값을 쓰는 곳이 없어 auto.js 한 곳만 고치면 된다).
"AI 추천 실패 — 타임스탬프만으로 배정했습니다" 문구는 로그 영역에 묻혀
사용자가 못 보던 경고였다. 무엇이 실패했고(Gemini 응답 없음) 그래서 어떻게
됐는지(분:초·자막단어·좋아요로 대체)가 한 문장에서 드러나도록 고쳤다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:43:39 +09:00

950 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 자동 탭 — 분석(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:"🤖",word:"🔤",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);
});
})();