/* 자동 탭 — 분석(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= '
'+ ''+ '
'+ '
'+ '
'+ '
👍 '+(c.likeCount||0).toLocaleString()+''+ '💬 '+(c.replyCount||0).toLocaleString()+'
'+ '
'; 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(//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; 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"; let shown=0; function render(batch){ const end=Math.min(list.length,shown+batch); for(;shown=list.length) more.remove(); else more.textContent="더보기 ▾ (남은 "+(list.length-shown).toLocaleString()+"장)"; applyUsedMarks(hlId); // 새로 그린 카드에도 사용중 표시 } more.addEventListener("click",()=>render(CARD_PAGE)); sec.appendChild(more); render(firstBatch||CARD_PAGE); return sec; } /* ── 컷별 선택 ── 카드는 '어느 컷 소속'인지가 배치를 정한다. selCut[hlId][idx] = 컷 인덱스. sel[hlId] 는 항상 컷 순서로 정렬해 둔다 (업로드 순서 = 배치 순서라서). */ const selCut={}; function cutOf(hlId,idx){ return (selCut[hlId]||{})[idx]; } 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='✔ 선택한 댓글'; list.forEach((idx,i)=>{ const c=byIdx[idx]; if(!c) return; const chip=document.createElement("span"); chip.className="selchip"; chip.innerHTML=''+(i+1)+''+ ''; 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=' 선택한 것만 보기'; 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=''+ '영상 편집안'+ ''+ '컷 '+cuts.length+"개 · "+(hl.total!=null?hl.total:0)+"초"; 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=''+(i+1)+""+ ''+fmtT(c.start||0)+"~"+fmtT(c.end||0)+ ''+dur.toFixed(1)+"초"+ ''; 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=> '
'+ '
'+esc(s.label)+'
'+ '
').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='ID '+hl.id+' ✗생성 실패 (클릭=사유)'; 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='ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+""+ ''; 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="

ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+ " "+esc(hl.reason||"")+"

"+ '
컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+ '
'; let opts=[]; if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이) html+='
제목: '+ ' '+ '
'; }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+='
제목:
"; } html+='
'; box.innerHTML=html; box.dataset.titles=JSON.stringify(opts); // 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘 box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id)); const WHY={ts:"⭐",ai:"🤖",like:"➕"}; if(hl.cuts){ // 컷별 추천 — 추천분 먼저, 그 컷 시간대 언급 댓글을 뒤에 붙여 갈아끼울 수 있게 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(""); const rest=(hl.matched||[]).filter(i=>byIdx[i]!==undefined&&!rec.includes(i)); 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)); } }else{ // 폴백 — 추천 실패 시 기존 화면 그대로 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))); } // ➕ 좋아요 상위 후보 (전체 — 30장씩 더보기) const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined); 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='▴ 댓글 접기카드 표시 상태'; 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=> '
'+ 'ID '+hl.id+''+ '⏳ 대기'+ '
').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); // 캡처는 보이는 상태에서 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; boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active"); try{ const blob=await captureCard(w); fd.append("cards",blob,String(n).padStart(3,"0")+".png"); sentCuts.push(cmap[idx]??((hl.paste.cuts||[]).length-1)); }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="

💬 구간 댓글

"+ '
구간 '+rng.length+"개 · 총 "+r.total+"초 · 카드 "+r.need+"장 필요 · "+ '
'+ ''+ '
'; 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(){ 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); }); })();