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=>
+ '
').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 댓글 수집·구간별 추천
+검토 화면에서 구간별 추천 댓글 카드를 확인·선택하고 선택한 카드로 드래프트 만들기
-- 출처: 비우면 유튜브 채널명 자동(@채널)
-- 댓글 카드: 폴더 경로 지정 시 하단에 3초마다 1장 자동 삽입
+- 제목·출처: 검토 영역 위 입력칸 — 분석이 끝난 뒤 오타를 고쳐도 재분석 없이 드래프트에 반영됩니다. 출처를 비워두면 유튜브 채널명 자동(@채널)
+- 댓글 카드: 검토에서 하나도 안 고르면 예전처럼 댓글 카드 폴더 지정으로 동작(3초마다 1장)
긴 구간일수록 다운로드·자막(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="처리 중…";