붙여넣기 탭에 댓글 선택 화면 추가 (Task 4) + 서버 매니페스트 보정

병렬 세션에서 구현된 것을 검증 후 커밋. 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) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-05 11:14:47 +09:00
parent 85101867d5
commit 447e8808ff
3 changed files with 195 additions and 31 deletions

View File

@ -221,6 +221,11 @@ async def stream(job_id: str) -> StreamingResponse:
return return
try: try:
if job.get("paste_state"): # 📋 붙여넣기 탭(새 흐름) — 이미 분석된 상태로 드래프트만 if job.get("paste_state"): # 📋 붙여넣기 탭(새 흐름) — 이미 분석된 상태로 드래프트만
# paste_draft 는 manifest 를 내지 않는다(analyze 쪽이 이미 냈던 스텝들과
# 별개 스트림이라 여기서 새로 내야 함) — 안 내면 step 이벤트만 홀로 와서
# 화면에 진행 표시가 아예 안 뜬다.
yield _sse({"type": "manifest",
"steps": [{"id": "draft", "label": "템플릿 드래프트 생성"}]})
stream_iter = paste_draft( stream_iter = paste_draft(
job["paste_state"], job["paste_state"],
video_scale=job.get("video_scale", 1.0), video_scale=job.get("video_scale", 1.0),
@ -726,11 +731,15 @@ async def paste_stream(aid: str) -> StreamingResponse:
return return
payload = a["payload"] payload = a["payload"]
warnings: list[str] = [] 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": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"}]}) {"id": "recommend", "label": "컷별 댓글 추천"}]})
com_task = asyncio.create_task( com_task = asyncio.create_task(
asyncio.to_thread(hlab.fetch_comments, payload["url"])) asyncio.to_thread(hlab.fetch_comments, payload["url"]))
yield _sse({"type": "step", "id": "comments", "status": "start"})
state = None state = None
try: try:
async for ev in paste_analyze(payload, "paste_" + aid, async for ev in paste_analyze(payload, "paste_" + aid,
@ -767,12 +776,21 @@ async def paste_stream(aid: str) -> StreamingResponse:
# orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다. # orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
places = state["card_places"] places = state["card_places"]
orig = [(s, e) for s, e, _, _ in state["cuts"]] orig = [(s, e) for s, e, _, _ in state["cuts"]]
cuts, need, ai_failed = await asyncio.to_thread( # 예상 밖 예외가 나면(감싸지 않았을 때) SSE 가 error 이벤트도 없이 끊기고
recommend.cuts_from_state, places, orig, state["bottom_caps"], comments) # PSTATES 도 안 남아 build 가 404 난다 — 추천 실패가 검토 화면 자체를 막으면 안 된다.
if ai_failed: try:
warnings.append("AI 추천 실패(Gemini 응답 없음) — 분:초·자막단어·좋아요로 배정했습니다") 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", 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} PSTATES[aid] = {"state": state, "places": places, "orig": orig, "payload": payload}
no_ts = [c for c in comments if not c["times"]] no_ts = [c for c in comments if not c["times"]]
@ -800,6 +818,7 @@ async def paste_build(
bg_white: str = Form(""), bg_white: str = Form(""),
cards_fixed: str = Form(""), cards_fixed: str = Form(""),
asr_bottom: str = Form("1"), asr_bottom: str = Form("1"),
comments_dir: str = Form(""),
) -> JSONResponse: ) -> JSONResponse:
"""📋 붙여넣기 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함).""" """📋 붙여넣기 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함)."""
st = PSTATES.get(aid) st = PSTATES.get(aid)
@ -830,7 +849,9 @@ async def paste_build(
sig = (aid + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|" sig = (aid + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
+ bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards))) + bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards)))
h = hashlib.sha1(sig.encode()).hexdigest()[:12] h = hashlib.sha1(sig.encode()).hexdigest()[:12]
cdir = "" # 카드가 오면 폴더 지정보다 우선(/youtube 와 같은 규칙). 카드를 하나도 안 골랐으면
# 화면에서 넘어온 폴더 경로를 그대로 써서 예전(폴더 지정) 동작으로 하위호환한다.
cdir = comments_dir.strip()
if cards: if cards:
cdir = os.path.join(COMMENTS_DIR, h) cdir = os.path.join(COMMENTS_DIR, h)
if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거 if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거

View File

@ -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 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");} 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 YT_HL=null; // 유튜브 구간 탭의 가상 하이라이트 {id:"yt", need}
let PASTE_HL=null; // 📋 붙여넣기 탭의 가상 하이라이트 {id:"paste", need, cuts, paste:{cuts}}
function hlById(id){ function hlById(id){
if(id==="yt") return YT_HL; if(id==="yt") return YT_HL;
if(id==="paste") return PASTE_HL;
return A.highlights.find(h=>h.id===id); return A.highlights.find(h=>h.id===id);
} }
@ -910,6 +912,148 @@ window.ytCC={
}, },
}; };
/* 📋 :
자동 탭과 같은 흐름(analyzeSSEonResultbuild)이지만 편집안이 하나뿐이라
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=>
'<div class="step" id="pst-'+s.id+'"><span class="sdot"></span>'+
'<div class="slabel"><div class="t">'+esc(s.label)+'</div><div class="sdetail" hidden></div></div>'+
'<div class="selapsed"></div></div>').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='<div class="hlmeta">컷 '+(ev.cuts||[]).length+"개 · 총 "+
(ev.total!=null?ev.total:0)+"초 · 카드 "+ev.need+"장 필요 · "+
'<span id="hlcnt-paste" class="tnum"></span></div>'+
'<div class="selbar" id="hlsel-paste"></div>';
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='<div class="bbrow" id="bb-paste">'+
'<span class="bbid">📋 붙여넣기</span><span class="bbstat">⏳ 대기</span>'+
'<span class="bbmsg"></span></div>';
$("#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(){ async function loadPrompts(){
try{ try{
@ -945,5 +1089,7 @@ document.addEventListener("DOMContentLoaded",()=>{
$("#pSave").addEventListener("click",()=>savePrompts(false)); $("#pSave").addEventListener("click",()=>savePrompts(false));
$("#pReset").addEventListener("click",()=>savePrompts(true)); $("#pReset").addEventListener("click",()=>savePrompts(true));
if($("#ytccBtn")) $("#ytccBtn").addEventListener("click",ytMatch); if($("#ytccBtn")) $("#ytccBtn").addEventListener("click",ytMatch);
if($("#pasteGo")) $("#pasteGo").addEventListener("click",pasteAnalyze);
if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll);
}); });
})(); })();

View File

@ -162,7 +162,7 @@
.comment-card.mosaic .cc-avatar{filter:blur(6px);} .comment-card.mosaic .cc-avatar{filter:blur(6px);}
.comment-card.mosaic .cc-author{filter:blur(5px);} .comment-card.mosaic .cc-author{filter:blur(5px);}
/* 검토 화면 — 본문 600px 유지, 검토 영역만 와이드 브레이크아웃(댓글 카드 3~4열) */ /* 검토 화면 — 본문 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);} width:var(--rw);margin-left:calc((100% - var(--rw))/2);}
/* ID 탭 바 — 스크롤해도 위에 붙어 있음 */ /* ID 탭 바 — 스크롤해도 위에 붙어 있음 */
.idtabs{position:sticky;top:0;z-index:40;display:flex;gap:6px;flex-wrap:wrap; .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;" 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='{&#10; "url": "https://www.youtube.com/watch?v=…",&#10; "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널",&#10; "cuts": [&#10; {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"},&#10; {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"}&#10; ]&#10;}'></textarea> placeholder='{&#10; "url": "https://www.youtube.com/watch?v=…",&#10; "title_top": "서브제목", "title_main": "메인제목", "channel": "@채널",&#10; "cuts": [&#10; {"start":"0:01.0","end":"0:03.5","bottom":"하단 자막\n두 줄","effect":"광속하강"},&#10; {"start":"2:33.5","end":"2:36.5","bottom":"다음 컷 자막","effect":"공포의통계"}&#10; ]&#10;}'></textarea>
</div> </div>
<div class="note" style="margin-top:8px;">한 URL의 여러 컷 + 자막을 그대로 사용합니다(무음컷·받아쓰기 없음). 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. <b>bottom</b>=하단자막, <b>effect</b>=중앙 효과자막(하단 바로 위).</div> <div class="note" style="margin-top:8px;">한 URL의 여러 컷 + 자막을 그대로 사용합니다. 컷은 순서대로 이어붙고, 배치 시간은 자동 계산돼요. <b>bottom</b>=하단자막, <b>effect</b>=중앙 효과자막(하단 바로 위). 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다.</div>
<button class="run" id="pasteGo" style="margin-top:8px;">댓글 매칭 시작</button>
</div> </div>
<div id="pasteSteps"></div>
<div id="pasteLog"></div>
<div id="pasteReview"></div>
<button class="run" id="pasteBuild" style="display:none;margin-top:14px;">선택한 카드로 드래프트 만들기</button>
<div id="pasteBoard" style="display:none;margin-top:10px;"></div>
<div id="pasteSummary" class="card" style="display:none;padding:18px;margin-top:12px;"></div>
</div> </div>
<!-- 자동(Gemini) 모드 --> <!-- 자동(Gemini) 모드 -->
@ -500,7 +508,7 @@
<input type="checkbox" id="bgwhite" checked style="accent-color:var(--accent);width:15px;height:15px;"> 배경 흰색 (위아래 띠·빈 곳을 흰색으로) <input type="checkbox" id="bgwhite" checked style="accent-color:var(--accent);width:15px;height:15px;"> 배경 흰색 (위아래 띠·빈 곳을 흰색으로)
</label> </label>
</div> </div>
<div class="field"> <div class="field" id="rmsilenceField">
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;"> <label style="display:flex;align-items:center;gap:7px;cursor:pointer;">
<input type="checkbox" id="rmsilence" style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (붙여넣기: 컷 안의 무음까지 잘라냄) <input type="checkbox" id="rmsilence" style="accent-color:var(--accent);width:15px;height:15px;"> 무음 제거 (붙여넣기: 컷 안의 무음까지 잘라냄)
</label> </label>
@ -575,7 +583,9 @@ function setMode(m){
$("#commonFields").style.display="grid"; $("#commonFields").style.display="grid";
$("#titleGroup").style.display=(m==="paste"||m==="auto")?"none":"block"; $("#titleGroup").style.display=(m==="paste"||m==="auto")?"none":"block";
$("#cdirField").style.display=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-file").addEventListener("click",()=>setMode("file"));
$("#tab-yt").addEventListener("click",()=>setMode("yt")); $("#tab-yt").addEventListener("click",()=>setMode("yt"));
@ -611,7 +621,8 @@ yt:`<h3>▶ 유튜브 구간 탭 사용법</h3>
paste:`<h3>📋 붙여넣기 탭 사용법</h3> paste:`<h3>📋 붙여넣기 탭 사용법</h3>
<ol> <ol>
<li>LLM(AI Studio 등)에게 아래 형식의 <b>JSON 하나</b>를 받아 붙여넣기</li> <li>LLM(AI Studio 등)에게 아래 형식의 <b>JSON 하나</b>를 받아 붙여넣기</li>
<li><b>편집 시작</b> → 컷 정밀 다운로드·병합 → 자막 → CapCut</li> <li><b>댓글 매칭 시작</b> → 컷 정밀 다운로드·병합 → 무음 제거 → 받아쓰기 → h-lab 댓글 매칭</li>
<li>컷마다 추천된 댓글 카드를 확인·선택하고 <b>선택한 카드로 드래프트 만들기</b></li>
</ol> </ol>
<pre>{ <pre>{
"url": "https://www.youtube.com/watch?v=실제ID", "url": "https://www.youtube.com/watch?v=실제ID",
@ -625,10 +636,11 @@ paste:`<h3>📋 붙여넣기 탭 사용법</h3>
<ul> <ul>
<li><b>url은 실제 영상 주소</b> — LLM이 지어낸 ID는 실패</li> <li><b>url은 실제 영상 주소</b> — LLM이 지어낸 ID는 실패</li>
<li>배치 시간은 넣지 마세요 — 컷 순서대로 자동 계산</li> <li>배치 시간은 넣지 마세요 — 컷 순서대로 자동 계산</li>
<li><b>무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용</b>됩니다(끌 수 없음)</li>
<li><b>하단 자막 자동 생성(Whisper) 켜짐</b>: bottom 무시, 실제 말 타이밍 자막 생성(추천)</li> <li><b>하단 자막 자동 생성(Whisper) 켜짐</b>: bottom 무시, 실제 말 타이밍 자막 생성(추천)</li>
<li>끄면: bottom 사용, \\n 은 시간 반씩 나눠 윗줄→아랫줄</li> <li>끄면(드래프트 만들기 단계): bottom 사용, \\n 은 시간 반씩 나눠 윗줄→아랫줄</li>
<li><b>effect</b>: 중앙 녹색 효과자막 · <b>댓글 카드</b>: 폴더 지정 시 3초마다 1장</li> <li><b>effect</b>: 중앙 녹색 효과자막</li>
<li><b>무음 제거</b> 켜면 컷 안의 무음까지 컷(자막 자동 보정)</li> <li><b>댓글 카드</b>: 컷별 추천에서 카드를 하나도 안 고르면 예전처럼 <b>댓글 카드 폴더</b> 지정으로 동작(3초마다 1장)</li>
</ul> </ul>
<div class="helpdim">헤더의 ✨ AI Studio에서 JSON 생성, 💬 댓글 카드에서 카드 저장.</div>`}; <div class="helpdim">헤더의 ✨ AI Studio에서 JSON 생성, 💬 댓글 카드에서 카드 저장.</div>`};
const overlay=$("#helpOverlay"); const overlay=$("#helpOverlay");
@ -756,22 +768,7 @@ runBtn.addEventListener("click",async()=>{
catch(e){return fail("요청 실패: "+e);} catch(e){return fail("요청 실패: "+e);}
if(res&&res.error){return fail(res.error);} if(res&&res.error){return fail(res.error);}
} }
if(mode==="paste"){ // 📋 붙여넣기 탭은 자체 버튼(#pasteGo → #pasteBuild, auto.js)으로 도므로 여기 안 옴.
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);}
}
runBtn.textContent="처리 중…"; runBtn.textContent="처리 중…";
runStream(res.job_id); runStream(res.job_id);
}); });