capcut-agent/server/static/auto.js

1427 lines
72 KiB
JavaScript
Raw Permalink 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; // 1차 결과(분석) {highlights} — 편집안·타이틀 후보만, 댓글 없음
let P=null; // 2차 결과(준비) {highlights:[{id,cuts,need,cutRanges,matched}],comments,candidates,warnings}
let AUTO_AID=null; // /auto/analyze 가 돌려준 analysis_id — /auto/prepare 에 넘긴다
let PREP_ID=null; // /auto/prepare 가 돌려준 prepare_id — SSE 구독용
let TITLE_PICKS={}; // hl.id → {top,main} — "준비 시작" 클릭 시점의 제목 선택을 잠가 둔다
let byIdx={}; // idx → comment
let sel={}; // hl.id → idx 배열(선택 순서 유지: matched 먼저)
let curId=null; // 현재 보고 있는 하이라이트 ID
let dropped=new Set(); // 1차 검토에서 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}
let PASTE_HL=null; // 📋 붙여넣기 탭의 가상 하이라이트 {id:"paste", need, cuts, paste:{cuts}}
function hlById(id){
if(id==="yt") return YT_HL;
if(id==="paste") return PASTE_HL;
// 자동 탭: cuts/need 는 2차(prepare) 결과에만 있다 — 카드 선택은 항상 2차에서만 일어난다.
return P&&P.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));
// 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로.
// 이 컷 추천(rec)에 이미 나온 카드는 뺀다 — 안 빼면 같은 컷 그룹 안에 같은 댓글이
// 두 장 뜬다(다른 컷의 복사본은 '이동'용이라 남기지만, 같은 컷 안은 순수 중복).
const fill=cand.filter(i=>!rec.includes(i));
if(fill.length)
body.appendChild(cardSection(
"↳ 좋아요 상위에서 채우기 "+fill.length+"장 (이 컷에 넣기)",
fill,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] 는 항상 컷 순서로 정렬해 둔다
(업로드 순서 = 배치 순서라서). */
let 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); // 탭 열 때 최신 선택 상태로 갱신
}
/* ── 1차 검토: ID 제외 / 되돌리기 ──
지우지 않고 '제외' 상태로만 둔다 → 실수로 눌러도 한 번에 복구 가능(되돌리기 지원).
제외된 ID는 /auto/prepare 에 안 넘어간다 — 받아쓰기를 아예 안 돌려 낭비가 없다(Task 7). */
function liveIds(){
if(!A) return [];
return A.highlights.filter(h=>!h.error&&!isDropped(h.id)).map(h=>h.id);
}
function updatePrepBtn(){
const btn=$("#autoPrepGo"); if(!btn||!A) return;
const n=liveIds().length;
btn.disabled=(n===0);
btn.textContent=n?(n+"개 준비 시작"):"준비할 ID가 없습니다 — 제외를 해제하세요";
}
function togglePickDrop(hlId){
const on=!isDropped(hlId);
if(on) dropped.add(String(hlId)); else dropped.delete(String(hlId));
const card=$("#pick-"+hlId);
if(card) card.classList.toggle("dropped-pick",on);
const btn=$("#pickx-"+hlId);
if(btn){
btn.textContent=on?"↺ 되돌리기":"✕ 제외";
btn.title=on?"다시 포함":"이 ID 제외 (준비 안 함)";
}
updatePrepBtn();
}
/* ── 2차 검토: '전부 만들기' 버튼 — 준비(prepare)를 마친 편집안 개수 기준 ── */
function updateBuildBtn(){
const btn=$("#autoBuild"); if(!btn||!P) return;
const n=(P.highlights||[]).length;
btn.disabled=(n===0);
btn.textContent=n?(n+"개 전부 만들기"):"만들 편집안이 없습니다";
}
/* ── 영상 편집안(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";
}
// ⚠ 네 모드 전부 /auto/prepare 가 댓글 매칭을 위해 asr_bottom=True 로 고정해서 돈다.
// 무음 제거는 패널의 #autoRmsilence 체크박스(기본 켬)를 폼으로 넘겨 선택 가능 —
// 댓글 매칭은 원본 시각(orig) 기준이라 무음 제거를 꺼도 어긋나지 않는다.
// (공통 옵션의 "무음 제거" 체크박스는 자동 탭에서 여전히 숨김 — setMode() 참조.)
const MODE_HELP={
full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다. 받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.",
whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.",
wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만들고, 제목은 JSON의 title_top·title_main을 그대로 씁니다. 1차 검토 없이 바로 다운로드·받아쓰기·댓글 매칭까지 진행되고 댓글 선택 화면으로 갑니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.",
politics:"정치 구간 JSON을 그대로 사용합니다. 댓글 수집과 검토를 생략하고, 125% 전면 영상+확대 배경의 정치 레이아웃으로 만든 뒤 CapCut을 자동 실행합니다.",
paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.",
};
const PASTE_UI={
paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)",
ph:'오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요.\n블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.',
note:"(선택 — 비우면 JSON의 url 사용)"},
wpaste:{label:"구간 JSON 붙여넣기 (candidates 배열 — 구간 5개 · title_top/title_main 선택)",
ph:'{\n "candidates": [\n {"id": 1, "start_time": "14:28", "end_time": "16:15", "reason": "구간 선정 이유",\n "title_top": "상단 제목 (선택)", "title_main": "메인 제목 (선택)"},\n … (총 5개)\n ]\n}',
note:"(필수 — 구간 JSON에는 URL이 없습니다)"},
politics:{label:"정치 구간 JSON 붙여넣기 (speaker·target·title_top·title_main 사용)",
ph:'{\n "candidates": [\n {"id": 1, "start_time": "04:27", "end_time": "06:21",\n "speaker": "발언자", "target": "관련 기관",\n "title_top": "상단 제목", "title_main": "메인 제목"}\n ]\n}',
note:"(필수 — 구간 JSON에는 URL이 없습니다)"},
};
function applyMode(){
const m=curMode();
const p=PASTE_UI[m];
const politics=m==="politics";
$("#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];
const runField=$("#autoRunField"); if(runField) runField.style.display=politics?"none":"block";
const silenceNote=$("#autoSilenceNote");
if(silenceNote) silenceNote.textContent=politics
?"받아쓰기(Whisper)는 적용합니다. 체크를 끄면 원본 구간 길이를 그대로 유지합니다."
:"받아쓰기(Whisper)·댓글 매칭은 항상 돌지만, 무음 제거는 꺼도 매칭이 어긋나지 않습니다.";
$("#autoGo").textContent=politics?"정치 숏폼 생성 → CapCut 자동 실행":
((p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)")+
(autoRunOn()?" → 영상까지 자동":""));
}
/* "끝까지 진행" — 검토 화면 두 곳(1차 제목 선택 · 2차 댓글 선택)에서 멈추지 않고
기본값·추천 그대로 다음 단계로 넘어간다. 화면 편의 기능이라 서버는 이 상태를 모른다. */
function autoRunOn(){const el=$("#autoAutoRun");return !!(el&&el.checked);}
/* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */
async function analyze(){
const m=curMode();
const url=$("#autoUrl").value.trim();
if(m!=="paste"&&!url){
alert((m==="wpaste"||m==="politics")?"유튜브 URL을 입력하세요. (구간 JSON에는 URL이 없습니다)"
:"유튜브 URL을 입력하세요.");return;}
if(PASTE_UI[m]&&!$("#apaste").value.trim()){
alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;}
$("#autoGo").disabled=true;$("#autoGo").textContent="분석 중…";
A=null;P=null;dropped=new Set();TITLE_PICKS={};AUTO_AID=null;PREP_ID=null;
$("#autoPickReview").innerHTML="";
$("#autoPrepGo").style.display="none";$("#autoPrepGo").disabled=false;
$("#autoPrepSteps").innerHTML="";$("#autoPrepSteps").style.display="none";
$("#autoPrepLog").innerHTML="";
$("#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);
AUTO_AID=res.analysis_id;
const es=new EventSource("/auto/stream/"+AUTO_AID);
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();
// 구간 JSON 모드: 제목이 JSON에 이미 있어 1차 검토(제목 선택)가 무의미 →
// 유효 구간이 있으면(autoPrepGo 표시 = ok>0) 바로 준비(다운로드·받아쓰기·댓글 매칭) 시작.
// "끝까지 진행"이 켜져 있으면 나머지 모드도 제목 기본값(첫 후보)으로 그냥 넘어간다.
if(m==="politics"){
TITLE_PICKS={};
for(const h of A.highlights) if(!h.error)
TITLE_PICKS[h.id]={top:h.paste.title_top||"",main:h.paste.title_main||""};
alog("정치 모드 — 댓글과 검토를 생략하고 바로 생성 준비를 시작합니다.");
prepareAll();
}else if((m==="wpaste"||autoRunOn())&&$("#autoPrepGo").style.display==="block"){
alog(m==="wpaste"?"구간 JSON 모드 — 제목이 JSON에 있으므로 바로 준비를 시작합니다."
:"끝까지 진행 — 제목은 기본 후보로 두고 바로 준비를 시작합니다.");
prepareAll();
}}
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;}}
}
/* ── 1차 검토 화면 — 하이라이트 카드(제목 선택 · ✕ 제외 · 편집안 접기). 댓글 영역 없음.
탭 전환 없이 세로로 쌓는다(보통 5개뿐이라 전부 보여도 부담 없다) — 지금 검토 화면에서
댓글 영역만 뺀 것이 1차 검토라는 설계를 그대로 따른다. */
function onResult(ev){
A=ev;
const R=$("#autoPickReview");R.innerHTML="";
let ok=0;
for(const hl of ev.highlights){
const card=document.createElement("div");
card.className="hlbox show pickcard";card.id="pick-"+hl.id;
if(hl.error){
card.classList.add("errbox");
card.innerHTML='<div class="pickhead"><h3 style="margin:0;">ID '+hl.id+' ✗</h3>'+
'<span style="color:var(--danger);font-size:12.5px;">생성 실패</span></div>'+
'<div class="hlmeta" style="color:var(--danger);">'+esc(hl.error)+'</div>';
R.appendChild(card);
continue;
}
ok++;
const cuts=hl.paste.cuts;
let html='<div class="pickhead"><h3 style="margin:0;">ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
"</h3><span style='color:var(--muted2);font-size:12.5px;'>"+esc(hl.reason||"")+"</span>"+
'<button type="button" class="ghost pickx" id="pickx-'+hl.id+'">✕ 제외</button></div>'+
'<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요</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>";
}
card.innerHTML=html;
if(hl.editable_title){ // 구간 JSON의 title_top/title_main 미리 채움 (.value라 따옴표 이스케이프 불필요)
const ti=card.querySelector("#hltop-"+hl.id),mi=card.querySelector("#hlmain-"+hl.id);
if(ti) ti.value=hl.paste.title_top||"";
if(mi) mi.value=hl.paste.title_main||"";
}
card.dataset.titles=JSON.stringify(opts);
card.appendChild(cutsSection(hl)); // 영상 편집안(컷 목록·JSON) — 기본 접힘
R.appendChild(card);
$("#pickx-"+hl.id).addEventListener("click",()=>togglePickDrop(hl.id));
}
if(ok){$("#autoPrepGo").style.display="block";updatePrepBtn();}
else $("#autoPrepGo").style.display="none";
}
/* ── "준비 시작" 클릭 시점의 제목 선택을 잠가 둔다 — 2차(prepare) 결과가 오면
1차 카드의 select/input 값을 더는 참조하지 않으므로 미리 읽어 저장해야 한다. */
function collectTitlePicks(){
const out={};
for(const hl of A.highlights){
if(hl.error||isDropped(hl.id)) continue;
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 card=$("#pick-"+hl.id);
const opts=card?JSON.parse(card.dataset.titles||"[]"):[];
const selEl=$("#hlt-"+hl.id);
pick=opts[parseInt(selEl?selEl.value:0,10)||0]||{top:"",main:""};
}
out[hl.id]=pick;
}
return out;
}
/* ── 2단계: 준비(다운로드·받아쓰기·댓글 매칭) — 제외 안 한 ID만, 서버가 순차로 돈다 ── */
async function prepareAll(){
if(!A) return;
const ids=liveIds();
if(!ids.length){alert("준비할 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;}
if(curMode()!=="politics") TITLE_PICKS=collectTitlePicks();
const btn=$("#autoPrepGo");
btn.disabled=true;btn.textContent="준비 중…";
$("#autoPrepSteps").innerHTML="";$("#autoPrepLog").innerHTML="";
$("#autoReview").innerHTML="";$("#autoSummary").style.display="none";
$("#autoBuild").style.display="none";
$("#buildBoard").style.display="none";$("#buildBoard").innerHTML="";
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="none";
let res;
try{
const fd=new FormData();
fd.append("aid",AUTO_AID);
fd.append("ids",JSON.stringify(ids));
const rs=$("#autoRmsilence");
fd.append("remove_silence",(!rs||rs.checked)?"1":""); // 체크박스 없으면 기본 켬
res=await(await fetch("/auto/prepare",{method:"POST",body:fd})).json();
}catch(e){return prepFail("요청 실패: "+e);}
if(res.error) return prepFail(res.error);
PREP_ID=res.prepare_id;
const es=new EventSource("/auto/prepare/"+PREP_ID);
es.onmessage=(ev0)=>{
const ev=JSON.parse(ev0.data);
if(ev.type==="manifest") renderPrepSteps(ev.steps);
else if(ev.type==="step") updatePrepStep(ev);
else if(ev.type==="log") prepLog(ev.msg);
else if(ev.type==="result"){es.close();onPrepareResult(ev);prepDone();}
else if(ev.type==="error"){es.close();prepFail(ev.message);}
};
es.onerror=()=>{es.close();prepFail("연결이 끊겼습니다.");};
}
function prepDone(){$("#autoPrepGo").disabled=false;updatePrepBtn();}
function prepFail(m){prepLog("⚠️ "+m);prepDone();}
function prepLog(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;$("#autoPrepLog").appendChild(d);
}
function renderPrepSteps(steps){
$("#autoPrepSteps").innerHTML=steps.map(s=>
'<div class="step" id="apst-'+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("");
$("#autoPrepSteps").style.display="block";
}
function updatePrepStep(ev){
const el=$("#apst-"+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;}}
}
/* ── 검토는 한 번에 하나 ──
byIdx/sel/selCut 는 탭(auto/yt/paste) 구분 없는 전역 상태다. 세 result 핸들러가
전부 byIdx={} 로 전역 리셋하면서 sel/selCut 은 자기 키만 건드리면, 다른 탭이
그려둔 카드가 방금 리셋된 byIdx/sel 을 참조해 "엉뚱한 댓글을 가리킴"·"ID n 사용중"
오표시·클릭 시 TypeError(toggle()의 list.indexOf, sel[hlId]가 undefined)가 난다.
→ 새 결과가 오면 sel/selCut 도 전역 리셋하고, 다른 탭의 검토 DOM·빌드 버튼까지
지워 클릭 경로 자체를 없앤다. */
const PANEL_DOM={auto:{review:"#autoReview",build:"#autoBuild"},
yt:{review:"#ytReview",build:"#ytBuild"},
paste:{review:"#pasteReview",build:"#pasteBuild"}};
function clearOtherPanels(exceptId){
for(const id in PANEL_DOM){
if(id===exceptId) continue;
const d=PANEL_DOM[id];
const r=$(d.review); if(r) r.innerHTML="";
const b=$(d.build); if(b) b.style.display="none";
}
}
/* ── 2차 검토 화면 — 지금까지와 같은 ID별 탭 + renderCutPanel(컷별 댓글 섹션). ──
준비(prepare)를 마친 ID만 온다 — 실패한 ID는 경고 로그로만 알린다(탭 자체가 없음). */
function onPrepareResult(ev){
P=ev;byIdx={};sel={};selCut={};curId=null;
clearOtherPanels("auto");
if(curMode()==="politics"){
(ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w));
$("#autoPickReview").innerHTML="";
$("#autoReview").innerHTML="";
if(!ev.highlights||!ev.highlights.length){
prepLog("⚠️ 준비된 정치 편집안이 없습니다.");
return;
}
prepLog("정치 모드 — 준비 완료, 댓글 카드 없이 드래프트를 생성합니다.");
setTimeout(()=>buildPolitics(ev.highlights),0);
return;
}
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
(ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w));
const R=$("#autoReview");R.innerHTML="";
if(!ev.highlights||!ev.highlights.length){
R.innerHTML='<div class="hlbox show"><div class="hlmeta">준비된 편집안이 없습니다. 위 로그를 확인하세요.</div></div>';
$("#autoBuild").style.display="none";
return;
}
const tabs=document.createElement("div");tabs.className="idtabs";tabs.id="idTabs";
R.appendChild(tabs);
let firstId=null;
for(const hl of ev.highlights){
const src=A.highlights.find(h=>h.id===hl.id)||{};
if(firstId===null) firstId=hl.id;
const tab=document.createElement("button");
tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id;
tab.innerHTML='<span class="tt">ID '+hl.id+(src.start!=null?(" · "+fmtT(src.start)+"~"+fmtT(src.end)):"")+
'</span><span class="ts"></span>';
tab.addEventListener("click",()=>showId(hl.id));
tabs.appendChild(tab);
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);
}
box.innerHTML='<div class="hlmeta">카드 '+hl.need+"장 필요 · "+
'<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>'+
'<div class="selbar" id="hlsel-'+hl.id+'"></div>';
renderCutPanel(box,hl.id,
{cuts:hl.cuts,matched:hl.matched,candidates:ev.candidates,cutRanges:hl.cutRanges},
{unit:"컷"});
R.appendChild(box);
refreshSel(hl.id);
}
// 접기/펼치기 버튼 — 탭 바 오른쪽 끝
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(firstId!==null) showId(firstId);
$("#autoBuild").style.display="block";
updateBuildBtn();
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex";
// "끝까지 진행" — 댓글 선택 화면에서 기다리지 않고, 위에서 자동 선택해 둔 컷별 추천을
// 그대로 확정해 바로 빌드. setTimeout(0)으로 이 SSE 핸들러를 끝낸 뒤 시작해야
// buildAll 의 캡처가 방금 그린 DOM의 레이아웃 확정 후에 돈다.
if(autoRunOn()&&!$("#autoBuild").disabled){
prepLog("끝까지 진행 — 추천 댓글을 그대로 쓰고 바로 영상 생성으로 넘어갑니다.");
setTimeout(buildAll,0);
}
}
async function buildPolitics(hls){
boardInit(hls);
let ok=0,fail=0;
for(const hl of hls){
boardSet(hl.id,"🔄 진행","정치 레이아웃 생성 중…","active");
try{
const pick=TITLE_PICKS[hl.id]||{top:"",main:""};
const fd=new FormData();
fd.append("aid",AUTO_AID); fd.append("id",String(hl.id));
fd.append("title_top",pick.top||""); fd.append("title_main",pick.main||"");
fd.append("video_scale","160");
fd.append("flip",$("#flip").checked?"1":"");
fd.append("scene",$("#scene").checked?"1":"");
fd.append("bg_white",""); fd.append("asr_bottom","1");
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)||"",""); ok++;
}catch(e){boardSet(hl.id,"❌ 실패",String(e.message||e),"err");fail++;}
}
const s=$("#autoSummary");s.style.display="block";
s.textContent=ok+"개 성공"+(fail?", "+fail+"개 실패":"")+" — 정치 레이아웃 생성 완료";
if(ok) fetch("/open-capcut",{method:"POST"});
}
/* ── 캡처 + 순차 빌드 ── */
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");
// 배경이 검정(흰색 배경 체크 해제)이면 흰 카드로 캡처 — 검은 카드는 검은 배경에 묻힌다.
// 캡처 순간에만 .light 를 붙였다 떼므로 검토 화면 표시는 어두운 테마 그대로다.
const light=!($("#bgwhite")&&$("#bgwhite").checked);
if(light) card.classList.add("light");
try{
return await window.modernScreenshot.domToBlob(card,{backgroundColor:null,scale:4});
}finally{
if(light) card.classList.remove("light");
}
}
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=(P&&P.highlights)||[]; // 준비(prepare)를 마친 ID만 — 실패·제외된 ID는 여기 없음
if(!hls.length){alert("만들 편집안이 없습니다.");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{
// 제목은 1차 검토("준비 시작" 클릭 시점)에 이미 잠가 뒀다 — 다운로드·받아쓰기가
// 끝난 지금은 1차 카드의 select/input 이 그대로 있어도 참조하지 않는다.
const pick=TITLE_PICKS[hl.id]||{top:"",main:""};
const fd=new FormData();
fd.append("aid",AUTO_AID);
fd.append("id",String(hl.id));
fd.append("title_top",pick.top||"");
fd.append("title_main",pick.main||"");
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("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("연결 끊김"));};
});
}
/* ── ▶ 유튜브 구간 탭: 분석(SSE) → 검토(컷별 카드 선택) → 생성 ──
📋 붙여넣기 탭(Task 4)과 같은 흐름 — 다만 "댓글 매칭 시작" 같은 전용 버튼이 없고
공용 "편집 시작"(#run, index.html 인라인 스크립트)이 분석을 돈다. 패널 하나
(panelId="yt")만 renderCutPanel 로 그린다(#hlbox-yt — wrapOf·clearSearch·
expandAllCuts·refreshSel 이 전부 이 id로 찾는다). */
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(){
// ⚠ 함정: 여기서 초 숫자로 바꿔 보내면 다운로드 경로(cut_youtube→valid_time)가
// "MM:SS"/"HH:MM:SS"만 받아들여 매 분석이 다운로드 단계에서 죽는다(구간 탭
// 전면 불능 — 서버는 방어적으로 재정규화하지만 프런트도 옛 fmtTime 동작대로
// 문자열 그대로 보내는 게 안전하다). 입력칸은 index.html의 focusout 리스너가
// 이미 fmtTime()으로 "16:07" 형식을 넣어 두므로 그 문자열을 그대로 쓴다.
const out=[];
document.querySelectorAll("#ranges .rng").forEach(row=>{
const sv=(row.querySelector(".rstart").value||"").trim();
const sSec=ytSec(sv);
if(sSec==null) return;
let ev2=(row.querySelector(".rend").value||"").trim();
let eSec=ytSec(ev2);
if(eSec==null){ eSec=sSec+90; ev2=fmtT(eSec); } // 끝 비면 시작+1:30 (실행 로직과 동일)
if(eSec>sSec) out.push([sv,ev2]);
});
return out;
}
let YT_AID=null;
function ytLog(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;$("#ytLog").appendChild(d);
}
function renderYSteps(steps){
$("#ytSteps").innerHTML=steps.map(s=>
'<div class="step" id="yst-'+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("");
$("#ytSteps").style.display="block";
}
function updateYStep(ev){
const el=$("#yst-"+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;}}
}
function ytDone(){$("#run").disabled=false;$("#run").textContent="편집 시작";}
function ytFail(m){ytLog("⚠️ "+m);ytDone();}
/* 분석 결과 → 검토 화면. 제목·출처는 분석(/yt/analyze)에 넘긴 값이 그대로 오거나
(channel 은 유튜브에서 자동인식됐을 수 있음) 여기서 입력칸에 반영해 보여준다 —
사용자가 여기서 또 고치면 그 값이 생성(ytBuildAll)에 그대로 실린다. */
function onYtResult(ev){
byIdx={};sel={};selCut={};curId=null; // 검토는 한 번에 하나 — 다른 탭 선택 상태까지 전부 리셋
clearOtherPanels("yt");
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
(ev.warnings||[]).forEach(w=>ytLog("⚠️ "+w));
if(ev.title_top!=null) $("#yttop").value=ev.title_top;
if(ev.title_main!=null) $("#ytmain").value=ev.title_main;
if(ev.channel) $("#ytchan").value=ev.channel; // 자동인식된 채널명 — 빈 값이면 건드리지 않음
YT_HL={id:"yt",need:ev.need,cuts:ev.cuts};
sel["yt"]=[];selCut["yt"]={};
// 컷별 추천 실패 시(ev.cuts=null) 붙여넣기 탭과 동일하게 매칭 댓글로 자동 선택
if(ev.cuts){
ev.cuts.forEach(cu=>{
for(const p of (cu.picks||[]))
if(byIdx[p.idx]!==undefined&&!sel["yt"].includes(p.idx)){
sel["yt"].push(p.idx);selCut["yt"][p.idx]=cu.i;
}
});
}else{
sel["yt"]=(ev.matched||[]).slice(0,ev.need).filter(i=>byIdx[i]!==undefined);
}
const area=$("#ytReview");area.innerHTML="";
const box=document.createElement("div");
box.className="hlbox show";box.id="hlbox-yt";
box.innerHTML='<div class="hlmeta">구간 '+(ev.cutRanges||[]).length+"개 · 총 "+
(ev.total!=null?ev.total:0)+"초 · 카드 "+ev.need+"장 필요 · "+
'<span id="hlcnt-yt" class="tnum"></span></div>'+
'<label class="selonly-label" style="margin-left:0;padding:2px 0;">'+
'<input type="checkbox" id="ytfixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+
' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+
'<div class="selbar" id="hlsel-yt"></div>';
renderCutPanel(box,"yt",
{cuts:ev.cuts,matched:ev.matched,candidates:ev.candidates,cutRanges:ev.cutRanges},
{unit:"구간"});
area.appendChild(box);
refreshSel("yt");
$("#ytBuild").style.display="block";
}
function ytAnalyze(){
const url=($("#yurl").value||"").trim();
if(!url){alert("유튜브 URL을 입력하세요.");return;}
const rng=ytRanges();
if(!rng.length){alert("구간을 하나 이상 입력하세요.");return;}
const btn=$("#run");
btn.disabled=true;btn.textContent="분석 중…";
$("#ytReview").innerHTML="";$("#ytSummary").style.display="none";
$("#ytBuild").style.display="none";$("#ytLog").innerHTML="";
$("#ytBoard").style.display="none";$("#ytBoard").innerHTML="";
const fd=new FormData();
fd.append("url",url);fd.append("ranges",JSON.stringify(rng));
fd.append("title_top",$("#yttop").value);
fd.append("title_main",$("#ytmain").value);
fd.append("channel",$("#ytchan").value);
// 무음 제거는 공통 옵션의 #rmsilence 체크박스(구간 탭에서 보임)를 그대로 넘긴다.
// 예전엔 이 값을 아무도 안 읽어서 체크를 꺼도 bg_analyze 가 무조건 무음을 잘랐다.
fd.append("remove_silence",$("#rmsilence")&&!$("#rmsilence").checked?"0":"1");
fetch("/yt/analyze",{method:"POST",body:fd}).then(r=>r.json()).then(res=>{
if(res.error){ytFail(res.error);return;}
YT_AID=res.analysis_id;
const es=new EventSource("/yt/stream/"+YT_AID);
es.onmessage=(m)=>{
const ev=JSON.parse(m.data);
if(ev.type==="manifest") renderYSteps(ev.steps);
else if(ev.type==="step") updateYStep(ev);
else if(ev.type==="log") ytLog(ev.msg);
else if(ev.type==="result"){es.close();onYtResult(ev);ytDone();}
else if(ev.type==="error"){es.close();ytFail(ev.message);}
};
es.onerror=()=>{es.close();ytFail("연결이 끊겼습니다.");};
}).catch(e=>ytFail("요청 실패: "+e));
}
window.ytAnalyze=ytAnalyze; // index.html 인라인 스크립트(#run 클릭)에서 호출하는 훅
/* ── 생성 — 📋 붙여넣기 탭 pasteBuildAll 과 같은 캡처 루프(캡처 함정 3종 방어 포함). ──
카드를 하나도 못 보내면 예전처럼 폴더 지정으로 하위호환한다. */
async function ytBuildAll(){
if(!YT_AID) return;
const btn=$("#ytBuild");
btn.disabled=true;
$("#ytBoard").innerHTML='<div class="bbrow" id="bb-yt">'+
'<span class="bbid">▶ 유튜브 구간</span><span class="bbstat">⏳ 대기</span>'+
'<span class="bbmsg"></span></div>';
$("#ytBoard").style.display="block";
$("#ytBoard").scrollIntoView({behavior:"smooth",block:"nearest"});
clearSearch("yt"); // 검색 중이면 카드가 DOM에서 빠져 캡처가 누락됨
expandAllCuts("yt"); // 접힌 컷 섹션의 카드는 빈 이미지로 캡처된다
const ybox=$("#hlbox-yt");
if(ybox) ybox.classList.remove("selonly"); // 배정 안 된 쌍둥이가 숨는 것 방지
await nextFrame();
boardSet("yt","🔄 진행","","active");
try{
const fd=new FormData();
fd.append("aid",YT_AID);
fd.append("title_top",$("#yttop").value);
fd.append("title_main",$("#ytmain").value);
fd.append("channel",$("#ytchan").value);
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");
const yf=$("#ytfixed");
fd.append("cards_fixed",(yf&&yf.checked)?"1":"0");
const cmap=selCut["yt"]||{};
const selList=sel["yt"]||[];
let n=0; const sentCuts=[];
for(const idx of selList){
const ci=cmap[idx];
const w=wrapOf("yt",idx,ci);
if(!w) continue;
if(YT_HL.cuts&&ci===undefined) continue; // 컷 소속 모르는 카드는 건너뜀
boardSet("yt",null,"카드 캡처 중… "+(++n)+"/"+selList.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("yt",null,"카드 1장 캡처 실패(건너뜀)","active");}
}
if(YT_HL.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));
if(n===0) fd.append("comments_dir",$("#cdir").value.trim()); // 카드 없음 → 폴더 지정 하위호환
boardSet("yt",null,"빌드 요청 중…","active");
const res=await(await fetch("/yt/build",{method:"POST",body:fd})).json();
if(res.error) throw new Error(res.error);
const r=await streamJob(res.job_id,"yt");
boardSet("yt","✅ 완료",(r&&r.draft_name)?r.draft_name:"","");
const s=$("#ytSummary");s.style.display="block";
s.textContent="완료 — CapCut 프로젝트 목록에서 드래프트를 여세요.";
if($("#autoopen")&&$("#autoopen").checked) fetch("/open-capcut",{method:"POST"});
}catch(e){
boardSet("yt","❌ 실패",String(e.message||e),"err");
}
btn.disabled=false;btn.textContent="다시 만들기";
}
/* ── 📋 붙여넣기 탭: 분석 → 검토 → 생성 ──
자동 탭과 같은 흐름(analyze→SSE→onResult→build)이지만 편집안이 하나뿐이라
ID 탭이 없다 — 패널 하나(panelId="paste")만 renderCutPanel 로 그린다. */
let PASTE_AID=null;
function pasteLog(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;$("#pasteLog").appendChild(d);
}
function renderPSteps(steps){
$("#pasteSteps").innerHTML=steps.map(s=>
'<div class="step" id="pst-'+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("");
$("#pasteSteps").style.display="block";
}
function updatePStep(ev){
const el=$("#pst-"+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;}}
}
function pasteDone(){$("#pasteGo").disabled=false;$("#pasteGo").textContent="댓글 매칭 시작";}
function pasteFail(m){pasteLog("⚠️ "+m);pasteDone();}
/* 컷별 추천 결과를 renderCutPanel 로 그린다. hlbox-paste 는 wrapOf·clearSearch·
expandAllCuts·refreshSel 이 전부 "#hlbox-"+panelId 로 찾으므로 id를 정확히 맞춰야 한다
(yt 탭의 #hlbox-yt 와 동일한 방식). */
function onPasteResult(ev){
byIdx={};sel={};selCut={};curId=null; // 검토는 한 번에 하나 — 다른 탭 선택 상태까지 전부 리셋
clearOtherPanels("paste");
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
(ev.warnings||[]).forEach(w=>pasteLog("⚠️ "+w));
PASTE_HL={id:"paste",need:ev.need,cuts:ev.cuts,paste:{cuts:ev.cutRanges}};
sel["paste"]=[];selCut["paste"]={};
// 컷별 추천 실패 시(ev.cuts=null) 자동 탭 폴백과 동일하게 매칭 댓글로 자동 선택
if(ev.cuts){
ev.cuts.forEach(cu=>{
for(const p of (cu.picks||[]))
if(byIdx[p.idx]!==undefined&&!sel["paste"].includes(p.idx)){
sel["paste"].push(p.idx);selCut["paste"][p.idx]=cu.i;
}
});
}else{
sel["paste"]=(ev.matched||[]).slice(0,ev.need).filter(i=>byIdx[i]!==undefined);
}
const area=$("#pasteReview");area.innerHTML="";
const box=document.createElement("div");
box.className="hlbox show";box.id="hlbox-paste";
box.innerHTML='<div class="hlmeta">컷 '+(ev.cuts||[]).length+"개 · 총 "+
(ev.total!=null?ev.total:0)+"초 · 카드 "+ev.need+"장 필요 · "+
'<span id="hlcnt-paste" class="tnum"></span></div>'+
'<div class="selbar" id="hlsel-paste"></div>';
renderCutPanel(box,"paste",
{cuts:ev.cuts,matched:ev.matched,candidates:ev.candidates,cutRanges:ev.cutRanges},
{unit:"컷"});
area.appendChild(box);
refreshSel("paste");
$("#pasteBuild").style.display="block";
}
function pasteAnalyze(){
const txt=$("#pjson").value.trim();
if(!txt){alert("편집안 JSON을 붙여넣으세요.");return;}
const btn=$("#pasteGo");
btn.disabled=true;btn.textContent="분석 중…";
$("#pasteReview").innerHTML="";$("#pasteSummary").style.display="none";
$("#pasteBuild").style.display="none";$("#pasteLog").innerHTML="";
$("#pasteBoard").style.display="none";$("#pasteBoard").innerHTML="";
const fd=new FormData();fd.append("data",txt);
fetch("/paste/analyze",{method:"POST",body:fd}).then(r=>r.json()).then(res=>{
if(res.error){pasteFail(res.error);return;}
PASTE_AID=res.analysis_id;
const es=new EventSource("/paste/stream/"+PASTE_AID);
es.onmessage=(m)=>{
const ev=JSON.parse(m.data);
if(ev.type==="manifest") renderPSteps(ev.steps);
else if(ev.type==="step") updatePStep(ev);
else if(ev.type==="log") pasteLog(ev.msg);
else if(ev.type==="result"){es.close();onPasteResult(ev);pasteDone();}
else if(ev.type==="error"){es.close();pasteFail(ev.message);}
};
es.onerror=()=>{es.close();pasteFail("연결이 끊겼습니다.");};
}).catch(e=>pasteFail("요청 실패: "+e));
}
/* ── 생성 — 자동 탭 buildAll의 캡처 루프를 그대로 따른다(캡처 함정 3종 방어 포함). ──
카드를 하나도 못 보내면(고르지 않았거나 캡처 전부 실패) 예전처럼 폴더 지정으로
하위호환한다 — comments_dir 필드를 그대로 넘긴다. */
async function pasteBuildAll(){
if(!PASTE_AID) return;
const btn=$("#pasteBuild");
btn.disabled=true;
$("#pasteBoard").innerHTML='<div class="bbrow" id="bb-paste">'+
'<span class="bbid">📋 붙여넣기</span><span class="bbstat">⏳ 대기</span>'+
'<span class="bbmsg"></span></div>';
$("#pasteBoard").style.display="block";
$("#pasteBoard").scrollIntoView({behavior:"smooth",block:"nearest"});
clearSearch("paste"); // 검색 중이면 카드가 DOM에서 빠져 캡처가 누락됨
expandAllCuts("paste"); // 접힌 컷 섹션의 카드는 빈 이미지로 캡처된다
const pbox=$("#hlbox-paste");
if(pbox) pbox.classList.remove("selonly"); // 배정 안 된 쌍둥이가 숨는 것 방지
await nextFrame();
boardSet("paste","🔄 진행","","active");
try{
const fd=new FormData();
fd.append("aid",PASTE_AID);
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("asr_bottom",$("#asrbottom").checked?"1":"0");
const cmap=selCut["paste"]||{};
const selList=sel["paste"]||[];
let n=0; const sentCuts=[];
for(const idx of selList){
const ci=cmap[idx];
const w=wrapOf("paste",idx,ci);
if(!w) continue;
if(PASTE_HL.cuts&&ci===undefined) continue; // 컷 소속 모르는 카드는 건너뜀
boardSet("paste",null,"카드 캡처 중… "+(++n)+"/"+selList.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("paste",null,"카드 1장 캡처 실패(건너뜀)","active");}
}
if(PASTE_HL.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));
if(n===0) fd.append("comments_dir",$("#cdir").value.trim()); // 카드 없음 → 폴더 지정 하위호환
boardSet("paste",null,"빌드 요청 중…","active");
const res=await(await fetch("/paste/build",{method:"POST",body:fd})).json();
if(res.error) throw new Error(res.error);
const r=await streamJob(res.job_id,"paste");
boardSet("paste","✅ 완료",(r&&r.draft_name)?r.draft_name:"","");
const s=$("#pasteSummary");s.style.display="block";
s.textContent="완료 — CapCut 프로젝트 목록에서 드래프트를 여세요.";
if($("#autoopen")&&$("#autoopen").checked) fetch("/open-capcut",{method:"POST"});
}catch(e){
boardSet("paste","❌ 실패",String(e.message||e),"err");
}
btn.disabled=false;btn.textContent="다시 만들기";
}
/* ── ⚙ 지침·모델 설정 ── */
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));
if($("#autoAutoRun")) $("#autoAutoRun").addEventListener("change",applyMode);
applyMode();
$("#autoPrepGo").addEventListener("click",prepareAll);
$("#autoBuild").addEventListener("click",buildAll);
$("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();});
$("#pSave").addEventListener("click",()=>savePrompts(false));
$("#pReset").addEventListener("click",()=>savePrompts(true));
if($("#ytBuild")) $("#ytBuild").addEventListener("click",ytBuildAll);
if($("#pasteGo")) $("#pasteGo").addEventListener("click",pasteAnalyze);
if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll);
});
/* ── 옵션 기억 — 체크 상태를 localStorage 에 저장, 다음 방문에도 유지.
서버는 이 상태를 모른다(폼 전송 값만 봄) — 순수 화면 편의 기능. */
(function(){
for(const [id,key] of [["#bgwhite","opt_bgwhite"],["#autoRmsilence","opt_auto_rmsilence"],
["#autoAutoRun","opt_auto_autorun"]]){
const el=$(id); if(!el) continue;
const saved=localStorage.getItem(key);
if(saved!==null) el.checked=saved==="1";
el.addEventListener("change",()=>localStorage.setItem(key,el.checked?"1":"0"));
}
})();
})();