검토 화면을 컷별 카드 묶음으로 렌더

서버가 하이라이트마다 컷 단위 추천(hl.cuts)을 내려주기 시작해서(6~8번
커밋), 화면도 컷 소속을 알아야 카드를 그 컷 구간 안에 배치할 수 있다.
카드가 어느 컷에 속하는지(selCut)를 추적해 컷별 상한(quota)을 걸고,
sel[hlId]를 항상 컷 순서로 정렬해 업로드 파일명(001.png…)과
card_cuts 배열 인덱스가 어긋나지 않게 했다. hl.cuts가 없으면(추천
실패) 기존  통짜 화면으로 그대로 폴백한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 12:28:11 +09:00
parent c3556edb9d
commit 5b58e94ad1

View File

@ -27,7 +27,7 @@ function hlById(id){
}
/* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */
function cardEl(c,hlId){
function cardEl(c,hlId,ci){
const wrap=document.createElement("div");
wrap.className="ccwrap"; wrap.dataset.cidx=c.idx;
const card=document.createElement("div");
@ -51,7 +51,7 @@ function cardEl(c,hlId){
const badge=document.createElement("div");
badge.className="ccbadge"; badge.textContent="✓";
wrap.appendChild(card); wrap.appendChild(badge);
wrap.addEventListener("click",()=>toggle(hlId,c.idx));
wrap.addEventListener("click",()=>toggle(hlId,c.idx,ci));
return wrap;
}
@ -61,7 +61,7 @@ function wrapOf(hlId,idx){
/* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */
const CARD_PAGE=30;
function cardSection(label,list,hlId,firstBatch){
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);
@ -75,7 +75,7 @@ function cardSection(label,list,hlId,firstBatch){
for(;shown<end;shown++){
const c=byIdx[list[shown]];
if(!c) continue;
const w=cardEl(c,hlId);
const w=cardEl(c,hlId,ci);
if(sel[hlId]&&sel[hlId].includes(c.idx)) w.classList.add("sel");
grid.appendChild(w);
}
@ -88,13 +88,34 @@ function cardSection(label,list,hlId,firstBatch){
render(firstBatch||CARD_PAGE);
return sec;
}
function toggle(hlId,idx){
/* ' ' .
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);
if(at>=0){ list.splice(at,1); delete selCut[hlId][idx]; }
else{
if(list.length>=hl.need) return; // 필요 장수 초과 선택 방지
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);
}
@ -435,7 +456,17 @@ function onResult(ev){
// 패널
const box=document.createElement("div");
box.className="hlbox";box.id="hlbox-"+hl.id;
sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
// 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백.
sel[hl.id]=[]; selCut[hl.id]={};
if(hl.cuts){
for(const cu of hl.cuts)
for(const p of (cu.picks||[]))
if(byIdx[p.idx]!==undefined&&!sel[hl.id].includes(p.idx)){
sel[hl.id].push(p.idx); selCut[hl.id][p.idx]=cu.i;
}
}else{
sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
}
const cuts=hl.paste.cuts;
let html="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
" <span style='color:var(--muted2);font-weight:400;'>"+esc(hl.reason||"")+"</span></h3>"+
@ -458,11 +489,25 @@ function onResult(ev){
box.dataset.titles=JSON.stringify(opts);
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
// ⭐ 구간 언급 댓글 (전체 — 30장씩 더보기)
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))); // 자동 선택분은 첫 화면에 다 보이게
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){
@ -551,7 +596,8 @@ async function buildAll(){
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");
let n=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;
@ -559,8 +605,10 @@ async function buildAll(){
try{
const blob=await captureCard(w);
fd.append("cards",blob,String(n).padStart(3,"0")+".png");
}catch(e){boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
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);