diff --git a/server/app.py b/server/app.py index 3b47f4c..7ba0dd5 100644 --- a/server/app.py +++ b/server/app.py @@ -354,7 +354,11 @@ async def yt_stream(aid: str) -> StreamingResponse: "cutRanges": [{"start": s, "end": e} for s, e in orig], "matched": matched, "candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)), - "comments": comments, "warnings": warnings}) + "comments": comments, "warnings": warnings, + # 검토 화면 입력칸을 채우기 위한 확정값 — channel 은 비워 뒀어도 + # bg_analyze 내부에서 유튜브 채널명으로 자동인식됐을 수 있다(state 에 반영됨). + "title_top": state["title_top"], "title_main": state["title_main"], + "channel": state["channel"]}) return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", @@ -366,6 +370,9 @@ async def yt_build( aid: str = Form(...), cards: list[UploadFile] = File(default=[]), card_cuts: str = Form(""), + title_top: str = Form(""), + title_main: str = Form(""), + channel: str = Form(""), video_scale: str = Form("100"), flip: str = Form(""), scene: str = Form(""), @@ -373,11 +380,21 @@ async def yt_build( cards_fixed: str = Form(""), comments_dir: str = Form(""), ) -> JSONResponse: - """▶ 유튜브 구간 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함).""" + """▶ 유튜브 구간 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함). + + title_top/title_main/channel 을 여기서도 받아 state 에 덮어쓴다: 검토 화면은 분석 + (/yt/analyze) 시점 값을 그대로 입력칸에 채워 보여주고(자동인식된 channel 포함) 여기서 + 고칠 수 있게 하는데, bg_draft 는 title_*/channel 을 오직 state 에서만 읽으므로 + (Task 6) 여기서 덮어쓰지 않으면 검토 화면에서 고친 값이 실제 드래프트에 반영되지 + 않는다 — 재분석(다운로드·ASR 재실행) 없이 오타만 고치는 게 이 필드들의 존재 이유다. + """ st = YSTATES.get(aid) if not st: return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404) state = st["state"] + state["title_top"] = title_top + state["title_main"] = title_main + state["channel"] = channel # 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백) cut_map: list[int] = [] diff --git a/server/static/auto.js b/server/static/auto.js index 8899c56..2290e00 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -841,8 +841,11 @@ function streamJob(jobId,hlId){ }); } -/* ── 유튜브 구간 탭: 댓글 매칭 (자동 탭과 동일 UI) ── */ -const YT_OFF=1000000; // 자동 탭 byIdx 와 충돌하지 않게 idx 오프셋 +/* ── ▶ 유튜브 구간 탭: 분석(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); @@ -860,57 +863,160 @@ function ytRanges(){ }); return out; } -async function ytMatch(){ +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=$("#ytccBtn");btn.disabled=true;btn.textContent="댓글 수집 중… (h-lab)"; - let r; + 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("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+"장 필요 · "+ - '
'+ - ''+ - '
'; - renderCutPanel(box,"yt", - {matched:matched,candidates:cand},{unit:"구간"}); - 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(){ - clearSearch("yt"); // 검색 중이면 카드가 DOM에 없어 캡처가 누락됨 - const out=[]; - for(const idx of sel["yt"]){ - const w=wrapOf("yt",idx); + 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; - try{out.push(await captureCard(w));}catch(e){/* 실패 카드는 건너뜀 */} + 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");} } - return out; - }, -}; + 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)이지만 편집안이 하나뿐이라 @@ -1088,7 +1194,7 @@ document.addEventListener("DOMContentLoaded",()=>{ $("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();}); $("#pSave").addEventListener("click",()=>savePrompts(false)); $("#pReset").addEventListener("click",()=>savePrompts(true)); - if($("#ytccBtn")) $("#ytccBtn").addEventListener("click",ytMatch); + if($("#ytBuild")) $("#ytBuild").addEventListener("click",ytBuildAll); if($("#pasteGo")) $("#pasteGo").addEventListener("click",pasteAnalyze); if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll); }); diff --git a/server/static/index.html b/server/static/index.html index 109b1de..d6503b2 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -162,7 +162,7 @@ .comment-card.mosaic .cc-avatar{filter:blur(6px);} .comment-card.mosaic .cc-author{filter:blur(5px);} /* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */ - #autoReview,#autoSummary,#ytccArea,#pasteReview,#pasteSummary{--rw:min(1240px,calc(100vw - 40px)); + #autoReview,#autoSummary,#ytReview,#ytSummary,#pasteReview,#pasteSummary{--rw:min(1240px,calc(100vw - 40px)); width:var(--rw);margin-left:calc((100% - var(--rw))/2);} /* ID 탭 바 — 스크롤해도 위에 붙어 있음 */ .idtabs{position:sticky;top:0;z-index:40;display:flex;gap:6px;flex-wrap:wrap; @@ -377,10 +377,23 @@
여러 구간을 넣으면 순서대로 이어붙여 하나의 캡컷 드래프트로 만듭니다. 형식: 분:초(03:30) 또는 시:분:초(01:03:30)
- -
구간을 언급한 댓글을 자동 선택하고, 나머지는 좋아요순으로 보여줍니다. 선택한 카드는 편집 시작 때 자동으로 들어갑니다(폴더 지정보다 우선).
-
+ +
+
+
+
+
+
+
비워두면 그 텍스트는 안 들어가요. 분석이 끝난 뒤에도 고칠 수 있고, 드래프트를 만들 때 여기 값이 반영됩니다.
+
+
+
+
+ + + @@ -579,9 +592,10 @@ function setMode(m){ $("#panel-yt").style.display=m==="yt"?"block":"none"; $("#panel-paste").style.display=m==="paste"?"block":"none"; $("#panel-auto").style.display=m==="auto"?"block":"none"; - // 붙여넣기·자동: 제목은 JSON에 있으니 숨김. 자동: 카드 폴더도 자동이라 숨김. + // 붙여넣기·자동: 제목은 JSON에 있으니 숨김. 구간: 검토 영역 위 전용 입력칸(#yttop 등)을 쓰므로 숨김. + // 자동: 카드 폴더도 자동이라 숨김. $("#commonFields").style.display="grid"; - $("#titleGroup").style.display=(m==="paste"||m==="auto")?"none":"block"; + $("#titleGroup").style.display=(m==="paste"||m==="auto"||m==="yt")?"none":"block"; $("#cdirField").style.display=m==="auto"?"none":"block"; $("#run").style.display=(m==="auto"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용 // 붙여넣기 탭은 댓글 매칭을 위해 무음 제거가 항상 켜진다 — 끌 수 있는 것처럼 보이면 안 됨 @@ -611,11 +625,12 @@ yt:`

▶ 유튜브 구간 탭 사용법

  • 유튜브 URL 입력 (실제 열리는 주소)
  • 구간 입력 — 시간형식 분:초 또는 시:분:초. 숫자만 쳐도 자동 변환(4314→43:14), 끝 비우면 시작+1분30초
  • + 구간 추가로 여러 구간 → 순서대로 이어붙여 하나의 드래프트
  • -
  • 편집 시작 → 다운로드·병합 → 무음 컷 + Whisper 자막 → CapCut
  • +
  • 편집 시작 → 다운로드·병합·무음 컷·Whisper 자막 → h-lab 댓글 수집·구간별 추천
  • +
  • 검토 화면에서 구간별 추천 댓글 카드를 확인·선택하고 선택한 카드로 드래프트 만들기
  • 긴 구간일수록 다운로드·자막(1분당 ≈30초) 시간이 늘어납니다.
    `, paste:`

    📋 붙여넣기 탭 사용법

    @@ -731,6 +746,13 @@ runBtn.addEventListener("click",async()=>{ window._runStart=Date.now(); // 시작~끝 총 시간 측정 logEl.innerHTML="";resultEl.style.display="none";resultEl.innerHTML=""; stepsEl.style.display="none";stepsEl.innerHTML=""; + if(mode==="yt"){ + // ▶ 유튜브 구간 탭: 이제 "편집 시작"이 분석(analyze→SSE→검토→생성)을 돈다 — + // 📋 붙여넣기 탭과 같은 흐름(auto.js 의 ytAnalyze). 진행·결과는 #ytSteps/#ytReview 등 + // 패널 전용 영역에 그려지므로 여기서는 넘기고 끝(#log/#steps/#result 는 안 씀). + if(window.ytAnalyze) window.ytAnalyze(); + return; + } let res; if(mode==="file"){ if(!picked){addLog("파일을 먼저 선택하세요.");return;} @@ -738,35 +760,6 @@ runBtn.addEventListener("click",async()=>{ const fd=new FormData();fd.append("file",picked);titleFields(fd); try{res=await(await fetch("/upload",{method:"POST",body:fd})).json();} catch(e){return fail("업로드 실패: "+e);} - }else if(mode==="yt"){ - const url=$("#yurl").value.trim(); - if(!url){addLog("유튜브 URL을 입력하세요.");return;} - // 모든 구간 수집(빈 행 제외). 끝 비면 시작+1:30 - const ranges=[]; - rangesEl.querySelectorAll(".rng").forEach(row=>{ - const si=row.querySelector(".rstart"), ei=row.querySelector(".rend"); - let st=fmtTime(si.value); - if(!st) return; - let en=ei.value.trim()?fmtTime(ei.value):fmtSec(toSec(st)+90); - si.value=st; ei.value=en; - ranges.push([st,en]); - }); - if(!ranges.length){addLog("구간을 하나 이상 입력하세요.");return;} - runBtn.disabled=true;runBtn.textContent="처리 중…"; - const fd=new FormData(); - fd.append("url",url);fd.append("ranges",JSON.stringify(ranges));titleFields(fd); - const yf=document.querySelector("#ytccFixed"); - if(yf) fd.append("cards_fixed",yf.checked?"1":"0"); - // 댓글 매칭에서 카드를 골랐으면 캡처해서 함께 전송(폴더 지정보다 우선) - if(window.ytCC&&window.ytCC.active()){ - runBtn.textContent="댓글 카드 캡처 중…"; - const blobs=await window.ytCC.capture(); - blobs.forEach((b,i)=>fd.append("cards",b,String(i+1).padStart(3,"0")+".png")); - runBtn.textContent="처리 중…"; - } - try{res=await(await fetch("/youtube",{method:"POST",body:fd})).json();} - catch(e){return fail("요청 실패: "+e);} - if(res&&res.error){return fail(res.error);} } // 📋 붙여넣기 탭은 자체 버튼(#pasteGo → #pasteBuild, auto.js)으로 도므로 여기 안 옴. runBtn.textContent="처리 중…";