붙여넣기 탭에 댓글 선택 화면 추가 (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:
parent
85101867d5
commit
447e8808ff
@ -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): # 재빌드 시 이전 카드 잔재 제거
|
||||||
|
|||||||
@ -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={
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── 📋 붙여넣기 탭: 분석 → 검토 → 생성 ──
|
||||||
|
자동 탭과 같은 흐름(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=>
|
||||||
|
'<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);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -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='{ "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":"공포의통계"} ] }'></textarea>
|
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":"공포의통계"} ] }'></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);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user