capcut-agent/server/static/auto.js
hehihoho3@gmail.com 20a7c0c0a2 feat: 댓글 선택 화면에 검색(필터) 추가
카드가 수백~수천 장이라 원하는 댓글을 눈으로 찾기 어려웠다. 패널마다 검색창을
두고 모든 카드 섹션이 자기 전체 목록에서 필터링한다 — '더보기'로 안 펼친 카드도
검색되면 나온다.

캡처 누락 방지: 빌드/캡처는 wrapOf() 로 DOM에서 카드를 찾고 없으면 조용히
건너뛴다. 검색으로 선택 카드가 DOM에서 빠질 수 있으므로 buildAll 과
ytCC.capture 시작 시 clearSearch() 로 강제 해제한다.

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

834 lines
38 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;
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();
}
/* ── 컷별 선택 ── 카드는 '어느 컷 소속'인지가 배치를 정한다.
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){ 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;
box.querySelectorAll(".ccwrap").forEach(w=>
w.classList.toggle("sel",list.includes(parseInt(w.dataset.cidx,10))));
// 탭 카운터
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;
box.querySelectorAll(".ccwrap").forEach(w=>{
const idx=parseInt(w.dataset.cidx,10);
const ids=usedByOthers(hlId,idx);
w.classList.toggle("used",ids.length>0);
let b=w.querySelector(".ccused");
if(ids.length){
if(!b){b=document.createElement("div");b.className="ccused";w.appendChild(b);}
b.textContent="ID "+ids.join("·")+" 사용중";
w.title="ID "+ids.join(", ")+"에서 이미 선택한 댓글";
}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));
box.appendChild(searchBar(hl.id)); // 카드 섹션들 위 — cardSection 보다 먼저 만들어야 SECS 초기화됨
const WHY={ts:"⭐",ai:"🤖",like:""};
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(hl.cuts){
/* 컷별 추천 — 컷마다 두 섹션. 독립 섹션을 두지 않는 이유:
거기서 고른 카드는 컷 소속(ci)이 없어 빌드 때 갈 곳이 없다. 그런데 AI 프롬프트가
"억지로 채우지 마라"라고 지시하므로 quota 미달이 기본이고, 미달분을 에서
채우는 게 주 워크플로다. _cards_by_cut 은 컷 '뒤'가 아니라 컷 '안에' 균등 분할하니
소속 없는 카드를 한 컷에 몰면 4초 컷에 5장이 들어가 0.8초씩 번쩍인다. */
for(const cu of hl.cuts){
const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined);
const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join("");
/* 추천 뒤에 붙이는 '나머지'는 **그 컷 시간대를 언급한 댓글만**.
hl.matched 전체를 컷마다 복제하면 컷 12개 × 30장 = 360장씩 부풀고,
같은 댓글이 여러 컷 섹션에 중복돼 한쪽을 고르면 쌍둥이까지 하이라이트된다. */
const cs=(hl.paste.cuts||[])[cu.i]||{start:0,end:-1};
const rest=(hl.matched||[]).filter(i=>
byIdx[i]!==undefined&&!rec.includes(i)&&
(byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end));
const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:"");
box.appendChild(cardSection(label,rec.concat(rest),hl.id,
Math.max(CARD_PAGE,rec.length),cu.i));
// 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로
if(cand.length)
box.appendChild(cardSection(
"↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)",
cand,hl.id,CUT_FILL_PAGE,cu.i));
}
}else{
// 폴백 — hl.cuts 가 없으면(컷 정보 없음) 예전 화면 그대로: ⭐ + 독립
const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined);
box.appendChild(cardSection(
"⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length)));
if(cand.length){
box.appendChild(cardSection(
" 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
cand,hl.id,CARD_PAGE));
}
}
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)));
/* 빌드 진행판 — 버튼 아래 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에 없어 캡처가 누락됨
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>';
box.appendChild(searchBar("yt"));
box.appendChild(cardSection(
"⭐ 구간을 언급한 댓글 "+matched.length+"장 (좋아요순, 자동 선택)",
matched,"yt",Math.max(CARD_PAGE,sel["yt"].length)));
if(cand.length){
box.appendChild(cardSection(
" 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
cand,"yt",CARD_PAGE));
}
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);
});
})();