diff --git a/server/static/auto.js b/server/static/auto.js index c75b31a..e73cf46 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -61,6 +61,8 @@ function wrapOf(hlId,idx){ /* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */ const CARD_PAGE=30; +// '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 DOM 폭증을 막는다 +const CUT_FILL_PAGE=6; function cardSection(label,list,hlId,firstBatch,ci){ const sec=document.createElement("div");sec.className="hlsec"; const head=document.createElement("div");head.textContent=label; @@ -92,7 +94,6 @@ function cardSection(label,list,hlId,firstBatch,ci){ 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)); @@ -490,30 +491,44 @@ function onResult(ev){ // 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘 box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id)); const WHY={ts:"⭐",ai:"🤖",like:"➕"}; + const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined); if(hl.cuts){ - // 컷별 추천 — 추천분 먼저, 그 컷 시간대 언급 댓글을 뒤에 붙여 갈아끼울 수 있게 + /* 컷별 추천 — 컷마다 두 섹션. 독립 ➕ 섹션을 두지 않는 이유: + 거기서 고른 카드는 컷 소속(ci)이 없어 빌드 때 갈 곳이 없다. 그런데 AI 프롬프트가 + "억지로 채우지 마라"라고 지시하므로 quota 미달이 기본이고, 미달분을 ➕ 에서 + 채우는 게 주 워크플로다. _cards_by_cut 은 컷 '뒤'가 아니라 컷 '안에' 균등 분할하니 + 소속 없는 카드를 한 컷에 몰면 4초 컷에 5장이 들어가 0.8초씩 번쩍인다. */ for(const cu of hl.cuts){ const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined); const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join(""); - const rest=(hl.matched||[]).filter(i=>byIdx[i]!==undefined&&!rec.includes(i)); + /* 추천 뒤에 붙이는 '나머지'는 **그 컷 시간대를 언급한 댓글만**. + hl.matched 전체를 컷마다 복제하면 컷 12개 × 30장 = 360장씩 부풀고, + 같은 댓글이 여러 컷 섹션에 중복돼 한쪽을 고르면 쌍둥이까지 하이라이트된다. */ + const cs=(hl.paste.cuts||[])[cu.i]||{start:0,end:-1}; + const rest=(hl.matched||[]).filter(i=> + byIdx[i]!==undefined&&!rec.includes(i)&& + (byIdx[i].times||[]).some(t=>t>=cs.start&&t<=cs.end)); const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+ (cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""); box.appendChild(cardSection(label,rec.concat(rest),hl.id, Math.max(CARD_PAGE,rec.length),cu.i)); + // 부족분 채우기 — 컷마다 반복되므로 처음엔 조금만 그리고 나머지는 더보기로 + if(cand.length) + box.appendChild(cardSection( + "↳ 좋아요 상위에서 채우기 "+cand.length+"장 (이 컷에 넣기)", + cand,hl.id,CUT_FILL_PAGE,cu.i)); } }else{ - // 폴백 — 추천 실패 시 기존 화면 그대로 + // 폴백 — hl.cuts 가 없으면(컷 정보 없음) 예전 화면 그대로: ⭐ + 독립 ➕ const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined); box.appendChild(cardSection( "⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)", m,hl.id,Math.max(CARD_PAGE,sel[hl.id].length))); - } - // ➕ 좋아요 상위 후보 (전체 — 30장씩 더보기) - const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined); - if(cand.length){ - box.appendChild(cardSection( - "➕ 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)", - cand,hl.id,CARD_PAGE)); + if(cand.length){ + box.appendChild(cardSection( + "➕ 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)", + cand,hl.id,CARD_PAGE)); + } } R.appendChild(box); refreshSel(hl.id); @@ -601,11 +616,17 @@ async function buildAll(){ for(const idx of sel[hl.id]){ const w=wrapOf(hl.id,idx); if(!w) continue; + const ci=cmap[idx]; + /* 컷 소속을 모르는 카드는 통째로 건너뛴다. 예전엔 마지막 컷으로 폴백했는데, + _cards_by_cut 은 컷 '안에' 균등 분할하므로 그러면 4초짜리 마지막 컷에 + 여러 장이 몰려 1초 미만으로 번쩍인다. 이제 모든 카드가 ci 를 갖지만 방어로 남긴다. */ + if(hl.cuts&&ci===undefined) continue; boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active"); try{ const blob=await captureCard(w); + // ⚠ append 와 push 는 반드시 쌍으로 — 하나만 돌면 카드가 통째로 엉뚱한 컷에 붙는다 fd.append("cards",blob,String(n).padStart(3,"0")+".png"); - sentCuts.push(cmap[idx]??((hl.paste.cuts||[]).length-1)); + sentCuts.push(ci); }catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");} } if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));