From 447e8808ff1ecb756bed04e8edb0794e7f430322 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Wed, 5 Aug 2026 11:14:47 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20?= =?UTF-8?q?=ED=83=AD=EC=97=90=20=EB=8C=93=EA=B8=80=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80=20(Task=204)=20+=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=A7=A4=EB=8B=88=ED=8E=98=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병렬 세션에서 구현된 것을 검증 후 커밋. Task 3 리뷰가 짚은 (a)~(e) 전부 반영: analyze manifest 에서 draft 스텝 제외(영원히 대기하던 것), comments start 이벤트, build 스트림 manifest, 무음 제거 체크박스 숨김(항상 켜지므로), cuts_from_state try/except(실패 시 cuts=None 으로 ⭐/➕ 폴백 화면). 캡처 함정 3종(검색 해제·컷 펼침·selonly 해제)과 append/push 쌍 실행, 캡처 실패 n-- 까지 자동 탭과 동일하게 처리됨을 확인함. Co-Authored-By: Claude Opus 5 (1M context) --- server/app.py | 35 ++++++++-- server/static/auto.js | 146 +++++++++++++++++++++++++++++++++++++++ server/static/index.html | 45 ++++++------ 3 files changed, 195 insertions(+), 31 deletions(-) diff --git a/server/app.py b/server/app.py index 05237cf..8db4cc9 100644 --- a/server/app.py +++ b/server/app.py @@ -221,6 +221,11 @@ async def stream(job_id: str) -> StreamingResponse: return try: if job.get("paste_state"): # 📋 붙여넣기 탭(새 흐름) — 이미 분석된 상태로 드래프트만 + # paste_draft 는 manifest 를 내지 않는다(analyze 쪽이 이미 냈던 스텝들과 + # 별개 스트림이라 여기서 새로 내야 함) — 안 내면 step 이벤트만 홀로 와서 + # 화면에 진행 표시가 아예 안 뜬다. + yield _sse({"type": "manifest", + "steps": [{"id": "draft", "label": "템플릿 드래프트 생성"}]}) stream_iter = paste_draft( job["paste_state"], video_scale=job.get("video_scale", 1.0), @@ -726,11 +731,15 @@ async def paste_stream(aid: str) -> StreamingResponse: return payload = a["payload"] warnings: list[str] = [] - yield _sse({"type": "manifest", "steps": pipeline_paste_steps(True) + + # draft 스텝은 이 단계(analyze)에서 안 돈다 — /paste/build 때 별도 스트림으로 실행되므로 + # 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다. + yield _sse({"type": "manifest", "steps": + [s for s in pipeline_paste_steps(True) if s["id"] != "draft"] + [{"id": "comments", "label": "댓글 수집 (h-lab)"}, {"id": "recommend", "label": "컷별 댓글 추천"}]}) com_task = asyncio.create_task( asyncio.to_thread(hlab.fetch_comments, payload["url"])) + yield _sse({"type": "step", "id": "comments", "status": "start"}) state = None try: async for ev in paste_analyze(payload, "paste_" + aid, @@ -767,12 +776,21 @@ async def paste_stream(aid: str) -> StreamingResponse: # orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다. places = state["card_places"] orig = [(s, e) for s, e, _, _ in state["cuts"]] - cuts, need, ai_failed = await asyncio.to_thread( - recommend.cuts_from_state, places, orig, state["bottom_caps"], comments) - if ai_failed: - warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다") + # 예상 밖 예외가 나면(감싸지 않았을 때) SSE 가 error 이벤트도 없이 끊기고 + # PSTATES 도 안 남아 build 가 404 난다 — 추천 실패가 검토 화면 자체를 막으면 안 된다. + try: + cuts, need, ai_failed = await asyncio.to_thread( + recommend.cuts_from_state, places, orig, state["bottom_caps"], comments) + if ai_failed: + warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다") + except Exception as exc: # noqa: BLE001 + # cuts=None(빈 리스트 아님)이어야 화면이 "컷 0개"로 비지 않고 ⭐/➕ 폴백 + # 화면(자동/구간 탭과 동일한 hl.cuts 없음 경로)으로 넘어간다. + cuts, need = None, max(1, int(state["timeline_dur"] // 3)) + warnings.append(f"컷별 추천 실패 — 컷 정보 없이 진행합니다 ({type(exc).__name__}: {exc})") yield _sse({"type": "step", "id": "recommend", "status": "done", - "detail": f"{len(cuts)}컷 · 카드 {need}장"}) + "detail": (f"{len(cuts)}컷 · 카드 {need}장" if cuts is not None + else f"폴백 · 카드 {need}장")}) PSTATES[aid] = {"state": state, "places": places, "orig": orig, "payload": payload} no_ts = [c for c in comments if not c["times"]] @@ -800,6 +818,7 @@ async def paste_build( bg_white: str = Form(""), cards_fixed: str = Form(""), asr_bottom: str = Form("1"), + comments_dir: str = Form(""), ) -> JSONResponse: """📋 붙여넣기 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함).""" st = PSTATES.get(aid) @@ -830,7 +849,9 @@ async def paste_build( sig = (aid + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|" + bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards))) h = hashlib.sha1(sig.encode()).hexdigest()[:12] - cdir = "" + # 카드가 오면 폴더 지정보다 우선(/youtube 와 같은 규칙). 카드를 하나도 안 골랐으면 + # 화면에서 넘어온 폴더 경로를 그대로 써서 예전(폴더 지정) 동작으로 하위호환한다. + cdir = comments_dir.strip() if cards: cdir = os.path.join(COMMENTS_DIR, h) if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거 diff --git a/server/static/auto.js b/server/static/auto.js index 729cda1..8899c56 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -21,8 +21,10 @@ function timeAgo(iso){ 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); } @@ -910,6 +912,148 @@ window.ytCC={ }, }; +/* ── 📋 붙여넣기 탭: 분석 → 검토 → 생성 ── + 자동 탭과 같은 흐름(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{ @@ -945,5 +1089,7 @@ document.addEventListener("DOMContentLoaded",()=>{ $("#pSave").addEventListener("click",()=>savePrompts(false)); $("#pReset").addEventListener("click",()=>savePrompts(true)); if($("#ytccBtn")) $("#ytccBtn").addEventListener("click",ytMatch); + 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 dc48226..109b1de 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{--rw:min(1240px,calc(100vw - 40px)); + #autoReview,#autoSummary,#ytccArea,#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; @@ -393,8 +393,16 @@ style="width:100%;box-sizing:border-box;background:var(--surf2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:11px;font-family:var(--mono);font-size:12.5px;line-height:1.5;resize:vertical;" placeholder='{ "url": "https://www.youtube.com/watch?v=…", "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널", "cuts": [ {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"}, {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"} ] }'> -
한 URL의 여러 컷 + 자막을 그대로 사용합니다(무음컷·받아쓰기 없음). 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. bottom=하단자막, effect=중앙 효과자막(하단 바로 위).
+
한 URL의 여러 컷 + 자막을 그대로 사용합니다. 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. bottom=하단자막, effect=중앙 효과자막(하단 바로 위). 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다.
+ + +
+
+
+ + + @@ -500,7 +508,7 @@ 배경 흰색 (위아래 띠·빈 곳을 흰색으로) -
+
@@ -575,7 +583,9 @@ function setMode(m){ $("#commonFields").style.display="grid"; $("#titleGroup").style.display=(m==="paste"||m==="auto")?"none":"block"; $("#cdirField").style.display=m==="auto"?"none":"block"; - $("#run").style.display=m==="auto"?"none":"block"; // 자동 탭은 자체 버튼 사용 + $("#run").style.display=(m==="auto"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용 + // 붙여넣기 탭은 댓글 매칭을 위해 무음 제거가 항상 켜진다 — 끌 수 있는 것처럼 보이면 안 됨 + $("#rmsilenceField").style.display=m==="paste"?"none":"block"; } $("#tab-file").addEventListener("click",()=>setMode("file")); $("#tab-yt").addEventListener("click",()=>setMode("yt")); @@ -611,7 +621,8 @@ yt:`

▶ 유튜브 구간 탭 사용법

paste:`

📋 붙여넣기 탭 사용법

  1. LLM(AI Studio 등)에게 아래 형식의 JSON 하나를 받아 붙여넣기
  2. -
  3. 편집 시작 → 컷 정밀 다운로드·병합 → 자막 → CapCut
  4. +
  5. 댓글 매칭 시작 → 컷 정밀 다운로드·병합 → 무음 제거 → 받아쓰기 → h-lab 댓글 매칭
  6. +
  7. 컷마다 추천된 댓글 카드를 확인·선택하고 선택한 카드로 드래프트 만들기
{
  "url": "https://www.youtube.com/watch?v=실제ID",
@@ -625,10 +636,11 @@ paste:`

📋 붙여넣기 탭 사용법

  • url은 실제 영상 주소 — LLM이 지어낸 ID는 실패
  • 배치 시간은 넣지 마세요 — 컷 순서대로 자동 계산
  • +
  • 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음)
  • 하단 자막 자동 생성(Whisper) 켜짐: bottom 무시, 실제 말 타이밍 자막 생성(추천)
  • -
  • 끄면: bottom 사용, \\n 은 시간 반씩 나눠 윗줄→아랫줄
  • -
  • effect: 중앙 녹색 효과자막 · 댓글 카드: 폴더 지정 시 3초마다 1장
  • -
  • 무음 제거 켜면 컷 안의 무음까지 컷(자막 자동 보정)
  • +
  • 끄면(드래프트 만들기 단계): bottom 사용, \\n 은 시간 반씩 나눠 윗줄→아랫줄
  • +
  • effect: 중앙 녹색 효과자막
  • +
  • 댓글 카드: 컷별 추천에서 카드를 하나도 안 고르면 예전처럼 댓글 카드 폴더 지정으로 동작(3초마다 1장)
헤더의 ✨ AI Studio에서 JSON 생성, 💬 댓글 카드에서 카드 저장.
`}; const overlay=$("#helpOverlay"); @@ -756,22 +768,7 @@ runBtn.addEventListener("click",async()=>{ catch(e){return fail("요청 실패: "+e);} if(res&&res.error){return fail(res.error);} } - if(mode==="paste"){ - const txt=$("#pjson").value.trim(); - if(!txt){addLog("편집안 JSON을 붙여넣으세요.");return;} - runBtn.disabled=true;runBtn.textContent="처리 중…"; - const fd=new FormData();fd.append("data",txt); - fd.append("video_scale",$("#vscale").value||"144"); - fd.append("flip",$("#flip").checked?"1":"0"); - fd.append("scene",$("#scene").checked?"1":"0"); - fd.append("comments_dir",$("#cdir").value.trim()); - fd.append("bg_white",$("#bgwhite").checked?"1":"0"); - fd.append("remove_silence",$("#rmsilence").checked?"1":"0"); - fd.append("asr_bottom",$("#asrbottom").checked?"1":"0"); - try{res=await(await fetch("/paste",{method:"POST",body:fd})).json();} - catch(e){return fail("요청 실패: "+e);} - if(res&&res.error){return fail(res.error);} - } + // 📋 붙여넣기 탭은 자체 버튼(#pasteGo → #pasteBuild, auto.js)으로 도므로 여기 안 옴. runBtn.textContent="처리 중…"; runStream(res.job_id); });