capcut-agent/server/static/auto.js
hehihoho3@gmail.com d1ebfaff74 자동 탭: 컷별 쌍둥이 카드 혼동 해소 + 컷 섹션 아코디언
같은 댓글이 컷마다 반복 렌더되는데 카드 DOM에 컷 번호가 없어 refreshSel()이
모든 복사본에 선택 링을 붙였다(1컷에서 고른 게 2컷에도 체크된 것처럼 보임).
쌍둥이를 눌러도 다른 컷으로 옮기려는 의도인데 무조건 해제됐다.

- cardEl에 data-ci를 남기고, refreshSel은 실제 배정된 컷의 복사본에만 링을 부여
- 다른 컷에 배정된 쌍둥이는 기존 .ccused 배지 구조를 재사용해 "컷 N에서 사용중"으로 표시
- 이미 선택된 쌍둥이를 다른 컷에서 누르면 해제 대신 그 컷으로 이동(sortSel로 순서 유지)
- 컷마다 추천+채우기 섹션을 아코디언으로 묶어 한 번에 한 컷만 펼침(카드 폭증 완화),
  헤더에 컷 채움 상태를 실시간 표시
- 빌드 캡처 직전 expandAllCuts()로 모든 컷 섹션을 강제로 펼침
  (검색 필터 때와 같은 함정 — 접힌 섹션의 카드는 domToBlob이 빈 이미지로 찍는다)

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

908 lines
42 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;
}
function wrapOf(hlId,idx){
return $("#hlbox-"+hlId+" .ccwrap[data-cidx='"+idx+"']");
}
/* ── 카드 섹션 — 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);
if(sel[hlId]&&sel[hlId].includes(c.idx)) 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;
}
/* ── 패널 검색창 — 입력하면 그 패널의 모든 섹션이 일치 카드만 다시 그림 ── */
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();
(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 w=wrapOf(hlId,idx);
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); // 접힌 컷 섹션의 카드는 빈 이미지로 캡처된다 — 전부 펼침
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 w=wrapOf(hl.id,idx);
if(!w) continue;
const ci=cmap[idx];
/* 컷 소속을 모르는 카드는 통째로 건너뛴다. 예전엔 마지막 컷으로 폴백했는데,
_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);
});
})();