섹션을 컷 안으로 흡수 — 고른 카드가 마지막 컷에 처박히지 않게

독립 " 좋아요 상위 후보" 섹션은 cardSection 에 ci 를 안 넘겨서
toggle(hlId,idx,undefined) → selCut 에 기록 안 됨 → 빌드 때 마지막 컷으로 폴백했다.
계획서는 "맨 뒤로 가니 마지막 컷 뒤에 붙는다, 배치상 문제 없다"고 적었는데 틀렸다.
_cards_by_cut 은 컷 뒤에 붙이지 않고 **컷 안에서 균등 분할**한다 —
4초짜리 마지막 컷에 5장이 들어가면 0.8초씩 번쩍인다.

AI 프롬프트가 "억지로 채우지 마라, 없으면 빈 배열로 둬라"라고 지시하므로
quota 미달이 기본 동작이고, 스펙 §7 은 그 미달을 "사람이 에서 채운다"로 풀었다.
즉 이건 예외가 아니라 주 워크플로다.

- hl.cuts 가 있으면 컷마다 두 섹션(둘 다 ci=cu.i):
  ① 컷 N · X초 · 카드 Q장 — 자막   ② ↳ 좋아요 상위에서 채우기
  ②는 컷마다 반복되므로 초기 렌더를 6장(CUT_FILL_PAGE)으로 줄이고 나머지는 더보기.
- 마지막 컷 폴백 제거. ci 가 없으면 엉뚱한 컷에 넣지 말고 그 카드를 건너뛴다
  (fd.append("cards") 와 sentCuts.push 는 반드시 쌍으로 — 하나만 돌면 전부 어긋난다).
- hl.cuts 가 없는 폴백 경로( + 독립 )는 그대로 둔다.

같이: 컷 섹션의 '나머지'를 그 컷 시간대 언급 댓글로 좁혔다.
hl.matched 전체를 컷마다 복제하면 컷 12개 × 30장 = 360장, 하이라이트 5개면 1800장으로
부풀고(기존 300장) 같은 댓글이 여러 섹션에 중복돼 한쪽을 고르면 쌍둥이까지 하이라이트됐다.
데이터(byIdx[i].times, paste.cuts[i].start/end)는 이미 클라이언트에 다 있다.
호출되지 않는 죽은 함수 cutOf() 도 제거.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 14:15:06 +09:00
parent 499223a125
commit c6092a93ac

View File

@ -61,6 +61,8 @@ function wrapOf(hlId,idx){
/* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */ /* ── 카드 섹션 — 30장씩 렌더 + 더보기 (전체 댓글을 받아도 DOM은 점진 생성) ── */
const CARD_PAGE=30; const CARD_PAGE=30;
// '좋아요 상위에서 채우기' 섹션은 컷마다 반복된다 — 초기 렌더를 줄여 DOM 폭증을 막는다
const CUT_FILL_PAGE=6;
function cardSection(label,list,hlId,firstBatch,ci){ function cardSection(label,list,hlId,firstBatch,ci){
const sec=document.createElement("div");sec.className="hlsec"; const sec=document.createElement("div");sec.className="hlsec";
const head=document.createElement("div");head.textContent=label; const head=document.createElement("div");head.textContent=label;
@ -92,7 +94,6 @@ function cardSection(label,list,hlId,firstBatch,ci){
selCut[hlId][idx] = 인덱스. sel[hlId] 항상 순서로 정렬해 둔다 selCut[hlId][idx] = 인덱스. sel[hlId] 항상 순서로 정렬해 둔다
(업로드 순서 = 배치 순서라서). */ (업로드 순서 = 배치 순서라서). */
const selCut={}; const selCut={};
function cutOf(hlId,idx){ return (selCut[hlId]||{})[idx]; }
function sortSel(hlId){ function sortSel(hlId){
const m=selCut[hlId]||{}; const m=selCut[hlId]||{};
sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999)); sel[hlId].sort((a,b)=>(m[a]??999)-(m[b]??999));
@ -490,31 +491,45 @@ function onResult(ev){
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘 // 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id)); box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
const WHY={ts:"⭐",ai:"🤖",like:""}; const WHY={ts:"⭐",ai:"🤖",like:""};
const cand=(hl.candidates||[]).filter(i=>byIdx[i]!==undefined);
if(hl.cuts){ if(hl.cuts){
// 컷별 추천 — 추천분 먼저, 그 컷 시간대 언급 댓글을 뒤에 붙여 갈아끼울 수 있게 /* . :
거기서 고른 카드는 소속(ci) 없어 빌드 곳이 없다. 그런데 AI 프롬프트가
"억지로 채우지 마라"라고 지시하므로 quota 미달이 기본이고, 미달분을 에서
채우는 워크플로다. _cards_by_cut '뒤' 아니라 '안에' 균등 분할하니
소속 없는 카드를 컷에 몰면 4 컷에 5장이 들어가 0.8초씩 번쩍인다. */
for(const cu of hl.cuts){ for(const cu of hl.cuts){
const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined); const rec=(cu.picks||[]).map(p=>p.idx).filter(i=>byIdx[i]!==undefined);
const why=(cu.picks||[]).map(p=>WHY[p.why]||"").join(""); 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+"장"+ const label="컷 "+(cu.i+1)+" · "+cu.sec+"초 · 카드 "+cu.quota+"장"+
(cu.bottom?" — "+cu.bottom:"")+(why?" "+why:""); (cu.bottom?" — "+cu.bottom:"")+(why?" "+why:"");
box.appendChild(cardSection(label,rec.concat(rest),hl.id, box.appendChild(cardSection(label,rec.concat(rest),hl.id,
Math.max(CARD_PAGE,rec.length),cu.i)); 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{ }else{
// 폴백 — 추천 실패 시 기존 화면 그대로 // 폴백 — hl.cuts 가 없으면(컷 정보 없음) 예전 화면 그대로: ⭐ + 독립
const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined); const m=(hl.matched||[]).filter(i=>byIdx[i]!==undefined);
box.appendChild(cardSection( box.appendChild(cardSection(
"⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)", "⭐ 이 구간을 언급한 댓글 "+m.length+"장 (좋아요순, 자동 선택)",
m,hl.id,Math.max(CARD_PAGE,sel[hl.id].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){ if(cand.length){
box.appendChild(cardSection( box.appendChild(cardSection(
" 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)", " 좋아요 상위 후보 "+cand.length+"장 (부족분 클릭)",
cand,hl.id,CARD_PAGE)); cand,hl.id,CARD_PAGE));
} }
}
R.appendChild(box); R.appendChild(box);
refreshSel(hl.id); refreshSel(hl.id);
} }
@ -601,11 +616,17 @@ async function buildAll(){
for(const idx of sel[hl.id]){ for(const idx of sel[hl.id]){
const w=wrapOf(hl.id,idx); const w=wrapOf(hl.id,idx);
if(!w) continue; 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"); boardSet(hl.id,null,"카드 캡처 중… "+(++n)+"/"+sel[hl.id].length,"active");
try{ try{
const blob=await captureCard(w); const blob=await captureCard(w);
// ⚠ append 와 push 는 반드시 쌍으로 — 하나만 돌면 카드가 통째로 엉뚱한 컷에 붙는다
fd.append("cards",blob,String(n).padStart(3,"0")+".png"); 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");} }catch(e){n--;boardSet(hl.id,null,"카드 1장 캡처 실패(건너뜀)","active");}
} }
if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts)); if(hl.cuts) fd.append("card_cuts",JSON.stringify(sentCuts));