/* 자동 탭 — 분석(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}
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;
return A.highlights.find(h=>h.id===id);
}
/* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */
function cardEl(c,hlId,ci){
const wrap=document.createElement("div");
wrap.className="ccwrap"; wrap.dataset.cidx=c.idx;
// 컷 소속을 DOM에 남긴다 — 없으면(폴백/구간 탭) 어느 복사본이 어느 컷 것인지 구분 불가해
// refreshSel 이 모든 복사본에 선택 링을 붙이는 버그가 난다(컷마다 후보 전체가 반복 렌더되므로).
if(ci!==undefined) wrap.dataset.ci=ci;
const card=document.createElement("div");
card.className="comment-card mosaic"; // 검정배경은 CSS 기본값
const av=c.profileImageUrl?"/auto/avatar?url="+encodeURIComponent(c.profileImageUrl):"";
card.innerHTML=
'
";
}
html+='';
box.innerHTML=html;
box.dataset.titles=JSON.stringify(opts);
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
renderCutPanel(box,hl.id,
{cuts:hl.cuts,matched:hl.matched,candidates:hl.candidates,
cutRanges:hl.paste.cuts},{unit:"컷"});
R.appendChild(box);
refreshSel(hl.id);
}
// 접기/펼치기 버튼 — 탭 바 오른쪽 끝
if(ok){
const fb=document.createElement("button");
fb.type="button";fb.className="idtab ccfoldbtn";fb.id="ccFoldBtn";
fb.innerHTML='▴ 댓글 접기카드 표시 상태';
fb.addEventListener("click",()=>setFolded(!$("#autoReview").classList.contains("folded")));
tabs.appendChild(fb);
}
if(firstOk!==null) showId(firstOk);
if(ok){
$("#autoBuild").style.display="block";
updateBuildBtn();
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex";
}
}
/* ── 캡처 + 순차 빌드 ── */
async function captureCard(wrap){
const img=wrap.querySelector(".cc-avatar");
if(img&&img.src&&!img.complete){ // 숨겨진 탭의 lazy 아바타 → 강제 로드 후 캡처
img.loading="eager";
try{await img.decode();}catch(e){}
}
const card=wrap.querySelector(".comment-card");
return await window.modernScreenshot.domToBlob(card,{backgroundColor:null,scale:4});
}
const nextFrame=()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
/* 빌드/캡처 전 필수(검색 초기화와 같은 이유) — 접힌 컷 섹션은 display:none 이라
레이아웃이 확정되지 않아 그 안의 카드를 domToBlob 으로 찍으면 빈 이미지가 나온다.
캡처는 wrapOf() 가 DOM에서 '첫 번째로 찾은' 복사본을 찍으므로, 그 복사본이 하필
접힌 컷 섹션 안에 있으면 조용히 깨진다 — 다음 사람이 "펼칠 필요 없는데?" 하고
지우면 이 버그가 다시 살아난다. 지우지 말 것. */
function expandAllCuts(hlId){
const box=$("#hlbox-"+hlId); if(!box) return;
box.querySelectorAll(".cutgrp").forEach(g=>g.classList.add("open"));
}
/* 빌드 진행판 — 버튼 아래 ID별 한 줄씩, 전체 상황이 한눈에 보임 */
function boardInit(hls){
const bd=$("#buildBoard");
bd.innerHTML=hls.map(hl=>
'
'+
'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); // 캡처는 보이는 상태에서
clearSearch(hl.id); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨
expandAllCuts(hl.id); // 접힌 컷 섹션의 카드는 빈 이미지로 캡처된다 — 전부 펼침
// '선택한 것만 보기'가 켜진 채면 배정 안 된 쌍둥이가 display:none으로 남는다.
// wrapOf(ci) 로 정확한 복사본을 찾도록 고쳤지만, 실패는 조용해서(빈 이미지·캡처 스킵)
// 사용자가 CapCut에서 드래프트를 열기 전엔 모른다 — 이중 방어로 아예 꺼 버린다.
const buildBox=$("#hlbox-"+hl.id);
if(buildBox) buildBox.classList.remove("selonly");
await nextFrame();
boardSet(hl.id,"🔄 진행","", "active");
try{
let pick;
if(hl.editable_title){
pick={top:($("#hltop-"+hl.id)?$("#hltop-"+hl.id).value.trim():""),
main:($("#hlmain-"+hl.id)?$("#hlmain-"+hl.id).value.trim():"")};
}else{
const opts=JSON.parse($("#hlbox-"+hl.id).dataset.titles);
pick=opts[parseInt($("#hlt-"+hl.id).value,10)||0];
}
const paste={...hl.paste,title_top:pick.top,title_main:pick.main};
const fd=new FormData();
fd.append("data",JSON.stringify(paste));
fd.append("tag","하이라이트"+hl.id);
fd.append("video_scale",$("#vscale").value||"144");
fd.append("flip",$("#flip").checked?"1":"0");
fd.append("scene",$("#scene").checked?"1":"0");
fd.append("bg_white",$("#bgwhite").checked?"1":"0");
fd.append("remove_silence",$("#rmsilence").checked?"1":"0");
fd.append("asr_bottom",$("#asrbottom").checked?"1":"0");
fd.append("cards_fixed",($("#autoCardsFixed")&&$("#autoCardsFixed").checked)?"1":"0");
const cmap=selCut[hl.id]||{};
let n=0; const sentCuts=[];
for(const idx of sel[hl.id]){
const ci=cmap[idx];
// ci를 반드시 wrapOf에 넘겨야 한다 — 안 넘기면 DOM에서 '처음 만나는' 복사본을
// 집는데, 컷 그룹은 컷 순서로 붙으므로 예를 들어 컷3에 배정된 댓글이라도 컷1의
// 채우기 섹션 복사본이 먼저 잡힐 수 있다(그 복사본은 배정 컷이 아니라 링도 없다).
const w=wrapOf(hl.id,idx,ci);
if(!w) continue;
/* 컷 소속을 모르는 카드는 통째로 건너뛴다. 예전엔 마지막 컷으로 폴백했는데,
_cards_by_cut 은 컷 '안에' 균등 분할하므로 그러면 4초짜리 마지막 컷에
여러 장이 몰려 1초 미만으로 번쩍인다. 이제 모든 카드가 ci 를 갖지만 방어로 남긴다. */
if(hl.cuts&&ci===undefined) continue;
boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
try{
const blob=await captureCard(w);
// ⚠ append 와 push 는 반드시 쌍으로 — 하나만 돌면 카드가 통째로 엉뚱한 컷에 붙는다
fd.append("cards",blob,String(n).padStart(3,"0")+".png");
sentCuts.push(ci);
}catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
}
if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));
boardSet(hl.id,null,"빌드 요청 중…","active");
const res=await(await fetch("/auto/build",{method:"POST",body:fd})).json();
if(res.error) throw new Error(res.error);
const r=await streamJob(res.job_id,hl.id); // 완료/실패까지 대기(순차)
boardSet(hl.id,"✅ 완료",(r&&r.draft_name)?r.draft_name:"","");
ok++;
const tab=$("#idtab-"+hl.id);
if(tab) tab.querySelector(".ts").textContent="✅ 완료";
}catch(e){
boardSet(hl.id,"❌ 실패",String(e.message||e),"err");fail++;
const tab=$("#idtab-"+hl.id);
if(tab) tab.querySelector(".ts").textContent="❌ 실패";
}
}
btn.disabled=false;btn.textContent="다시 만들기";
const s=$("#autoSummary");s.style.display="block";
s.textContent=ok+"개 성공"+(fail?", "+fail+"개 실패":"")+
" — CapCut 프로젝트 목록에서 드래프트를 여세요.";
if(ok&&$("#autoopen")&&$("#autoopen").checked)
fetch("/open-capcut",{method:"POST"});
}
function streamJob(jobId,hlId){
return new Promise((resolve,reject)=>{
const es=new EventSource("/stream/"+jobId);
es.onmessage=(m)=>{
const ev=JSON.parse(m.data);
if(ev.type==="log") boardSet(hlId,null,ev.msg,"active");
else if(ev.type==="step"&&ev.status==="start") boardSet(hlId,null,"▶ "+ev.id,"active");
else if(ev.type==="step"&&ev.status==="done"&&ev.detail)
boardSet(hlId,null,"✓ "+ev.id+" — "+ev.detail,"active");
else if(ev.type==="result"){es.close();resolve(ev);}
else if(ev.type==="error"){es.close();reject(new Error(ev.message));}
};
es.onerror=()=>{es.close();reject(new Error("연결 끊김"));};
});
}
/* ── ▶ 유튜브 구간 탭: 분석(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(){
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;
}
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=>
'
'+
'
'+esc(s.label)+'
'+
'
').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={};(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='
구간 '+(ev.cutRanges||[]).length+"개 · 총 "+
(ev.total!=null?ev.total:0)+"초 · 카드 "+ev.need+"장 필요 · "+
'
'+
''+
'';
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);
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='
'+
'▶ 유튜브 구간⏳ 대기'+
'
';
$("#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=>
'
'+
'
'+esc(s.label)+'
'+
'
').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={};(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='
컷 '+(ev.cuts||[]).length+"개 · 총 "+
(ev.total!=null?ev.total:0)+"초 · 카드 "+ev.need+"장 필요 · "+
'
'+
'';
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='
'+
'📋 붙여넣기⏳ 대기'+
'
';
$("#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));
applyMode();
$("#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);
});
})();