유튜브 구간 탭에 붙여넣기 탭과 같은 분석→검토→생성 흐름 연결 (Task 6)
죽은 /yt/comments, /youtube 를 부르던 구식 화면(💬 구간 댓글 매칭 버튼, ytMatch,
window.ytCC)을 걷어내고 "편집 시작"이 /yt/analyze→SSE /yt/stream 을 돌게 했다.
검토 영역 위에 제목·출처 입력칸을 둬 분석 후에도 고칠 수 있게 하고, 생성
(/yt/build) 때 그 값을 보낸다 — bg_draft 가 title_*/channel 을 state 에서만
읽으므로 /yt/build 에도 그 세 필드를 추가해 state 를 덮어쓰게 했다(server/app.py,
브리프의 파일 목록엔 없었지만 이 요구사항 자체가 성립하려면 꼭 필요했다).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
678d3f20e5
commit
3c013dcc88
@ -354,7 +354,11 @@ async def yt_stream(aid: str) -> StreamingResponse:
|
|||||||
"cutRanges": [{"start": s, "end": e} for s, e in orig],
|
"cutRanges": [{"start": s, "end": e} for s, e in orig],
|
||||||
"matched": matched,
|
"matched": matched,
|
||||||
"candidates": hlab.top_liked(no_ts, set(matched), len(no_ts)),
|
"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",
|
return StreamingResponse(gen(), media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache",
|
headers={"Cache-Control": "no-cache",
|
||||||
@ -366,6 +370,9 @@ async def yt_build(
|
|||||||
aid: str = Form(...),
|
aid: str = Form(...),
|
||||||
cards: list[UploadFile] = File(default=[]),
|
cards: list[UploadFile] = File(default=[]),
|
||||||
card_cuts: str = Form(""),
|
card_cuts: str = Form(""),
|
||||||
|
title_top: str = Form(""),
|
||||||
|
title_main: str = Form(""),
|
||||||
|
channel: str = Form(""),
|
||||||
video_scale: str = Form("100"),
|
video_scale: str = Form("100"),
|
||||||
flip: str = Form(""),
|
flip: str = Form(""),
|
||||||
scene: str = Form(""),
|
scene: str = Form(""),
|
||||||
@ -373,11 +380,21 @@ async def yt_build(
|
|||||||
cards_fixed: str = Form(""),
|
cards_fixed: str = Form(""),
|
||||||
comments_dir: str = Form(""),
|
comments_dir: str = Form(""),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""▶ 유튜브 구간 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함)."""
|
"""▶ 유튜브 구간 탭(새 흐름) 3단계 — 보관한 상태로 드래프트만 만든다(다운로드·받아쓰기 다시 안 함).
|
||||||
|
|
||||||
|
title_top/title_main/channel 을 여기서도 받아 state 에 덮어쓴다: 검토 화면은 분석
|
||||||
|
(/yt/analyze) 시점 값을 그대로 입력칸에 채워 보여주고(자동인식된 channel 포함) 여기서
|
||||||
|
고칠 수 있게 하는데, bg_draft 는 title_*/channel 을 오직 state 에서만 읽으므로
|
||||||
|
(Task 6) 여기서 덮어쓰지 않으면 검토 화면에서 고친 값이 실제 드래프트에 반영되지
|
||||||
|
않는다 — 재분석(다운로드·ASR 재실행) 없이 오타만 고치는 게 이 필드들의 존재 이유다.
|
||||||
|
"""
|
||||||
st = YSTATES.get(aid)
|
st = YSTATES.get(aid)
|
||||||
if not st:
|
if not st:
|
||||||
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
|
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
|
||||||
state = st["state"]
|
state = st["state"]
|
||||||
|
state["title_top"] = title_top
|
||||||
|
state["title_main"] = title_main
|
||||||
|
state["channel"] = channel
|
||||||
|
|
||||||
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백)
|
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백)
|
||||||
cut_map: list[int] = []
|
cut_map: list[int] = []
|
||||||
|
|||||||
@ -841,8 +841,11 @@ function streamJob(jobId,hlId){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 유튜브 구간 탭: 댓글 매칭 (자동 탭과 동일 UI) ── */
|
/* ── ▶ 유튜브 구간 탭: 분석(SSE) → 검토(컷별 카드 선택) → 생성 ──
|
||||||
const YT_OFF=1000000; // 자동 탭 byIdx 와 충돌하지 않게 idx 오프셋
|
📋 붙여넣기 탭(Task 4)과 같은 흐름 — 다만 "댓글 매칭 시작" 같은 전용 버튼이 없고
|
||||||
|
공용 "편집 시작"(#run, index.html 인라인 스크립트)이 분석을 돈다. 패널 하나
|
||||||
|
(panelId="yt")만 renderCutPanel 로 그린다(#hlbox-yt — wrapOf·clearSearch·
|
||||||
|
expandAllCuts·refreshSel 이 전부 이 id로 찾는다). */
|
||||||
function ytSec(v){
|
function ytSec(v){
|
||||||
v=(v||"").trim(); if(!v) return null;
|
v=(v||"").trim(); if(!v) return null;
|
||||||
const p=v.split(":").map(Number);
|
const p=v.split(":").map(Number);
|
||||||
@ -860,57 +863,160 @@ function ytRanges(){
|
|||||||
});
|
});
|
||||||
return out;
|
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=>
|
||||||
|
'<div class="step" id="yst-'+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("");
|
||||||
|
$("#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='<div class="hlmeta">구간 '+(ev.cutRanges||[]).length+"개 · 총 "+
|
||||||
|
(ev.total!=null?ev.total:0)+"초 · 카드 "+ev.need+"장 필요 · "+
|
||||||
|
'<span id="hlcnt-yt" class="tnum"></span></div>'+
|
||||||
|
'<label class="selonly-label" style="margin-left:0;padding:2px 0;">'+
|
||||||
|
'<input type="checkbox" id="ytfixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+
|
||||||
|
' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+
|
||||||
|
'<div class="selbar" id="hlsel-yt"></div>';
|
||||||
|
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();
|
const url=($("#yurl").value||"").trim();
|
||||||
if(!url){alert("유튜브 URL을 입력하세요.");return;}
|
if(!url){alert("유튜브 URL을 입력하세요.");return;}
|
||||||
const rng=ytRanges();
|
const rng=ytRanges();
|
||||||
if(!rng.length){alert("구간을 하나 이상 입력하세요.");return;}
|
if(!rng.length){alert("구간을 하나 이상 입력하세요.");return;}
|
||||||
const btn=$("#ytccBtn");btn.disabled=true;btn.textContent="댓글 수집 중… (h-lab)";
|
const btn=$("#run");
|
||||||
let r;
|
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='<div class="bbrow" id="bb-yt">'+
|
||||||
|
'<span class="bbid">▶ 유튜브 구간</span><span class="bbstat">⏳ 대기</span>'+
|
||||||
|
'<span class="bbmsg"></span></div>';
|
||||||
|
$("#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{
|
try{
|
||||||
const fd=new FormData();
|
const fd=new FormData();
|
||||||
fd.append("url",url);fd.append("ranges",JSON.stringify(rng));
|
fd.append("aid",YT_AID);
|
||||||
r=await(await fetch("/yt/comments",{method:"POST",body:fd})).json();
|
fd.append("title_top",$("#yttop").value);
|
||||||
}catch(e){r={error:"요청 실패: "+e};}
|
fd.append("title_main",$("#ytmain").value);
|
||||||
btn.disabled=false;btn.textContent="💬 구간 댓글 다시 매칭";
|
fd.append("channel",$("#ytchan").value);
|
||||||
if(r.error){$("#ytccNote").textContent="⚠ "+r.error;return;}
|
fd.append("video_scale",$("#vscale").value||"144");
|
||||||
// idx 오프셋 후 등록 (자동 탭 상태와 공존)
|
fd.append("flip",$("#flip").checked?"1":"0");
|
||||||
(r.comments||[]).forEach(c=>{c.idx+=YT_OFF;byIdx[c.idx]=c;});
|
fd.append("scene",$("#scene").checked?"1":"0");
|
||||||
const matched=(r.matched||[]).map(i=>i+YT_OFF).filter(i=>byIdx[i]);
|
fd.append("bg_white",$("#bgwhite").checked?"1":"0");
|
||||||
const cand=(r.candidates||[]).map(i=>i+YT_OFF).filter(i=>byIdx[i]);
|
const yf=$("#ytfixed");
|
||||||
YT_HL={id:"yt",need:r.need};
|
fd.append("cards_fixed",(yf&&yf.checked)?"1":"0");
|
||||||
sel["yt"]=matched.slice(0,r.need);
|
const cmap=selCut["yt"]||{};
|
||||||
const area=$("#ytccArea");area.innerHTML="";
|
const selList=sel["yt"]||[];
|
||||||
const box=document.createElement("div");
|
let n=0; const sentCuts=[];
|
||||||
box.className="hlbox show";box.id="hlbox-yt";
|
for(const idx of selList){
|
||||||
box.innerHTML="<h3>💬 구간 댓글</h3>"+
|
const ci=cmap[idx];
|
||||||
'<div class="hlmeta">구간 '+rng.length+"개 · 총 "+r.total+"초 · 카드 "+r.need+"장 필요 · "+
|
const w=wrapOf("yt",idx,ci);
|
||||||
'<span id="hlcnt-yt" class="tnum"></span></div>'+
|
|
||||||
'<label class="selonly-label" style="margin-left:0;padding:2px 0;">'+
|
|
||||||
'<input type="checkbox" id="ytccFixed" checked style="accent-color:var(--accent);width:14px;height:14px;">'+
|
|
||||||
' 카드 3초 고정 — 모자라도 늘리지 않고 뒤는 비움 (부분삭제 편집용)</label>'+
|
|
||||||
'<div class="selbar" id="hlsel-yt"></div>';
|
|
||||||
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);
|
|
||||||
if(!w) continue;
|
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)이지만 편집안이 하나뿐이라
|
자동 탭과 같은 흐름(analyze→SSE→onResult→build)이지만 편집안이 하나뿐이라
|
||||||
@ -1088,7 +1194,7 @@ document.addEventListener("DOMContentLoaded",()=>{
|
|||||||
$("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();});
|
$("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();});
|
||||||
$("#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($("#ytBuild")) $("#ytBuild").addEventListener("click",ytBuildAll);
|
||||||
if($("#pasteGo")) $("#pasteGo").addEventListener("click",pasteAnalyze);
|
if($("#pasteGo")) $("#pasteGo").addEventListener("click",pasteAnalyze);
|
||||||
if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll);
|
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,#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);}
|
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;
|
||||||
@ -377,10 +377,23 @@
|
|||||||
<button type="button" id="addrng"
|
<button type="button" id="addrng"
|
||||||
style="margin-top:8px;width:100%;padding:9px;background:transparent;color:var(--accent);border:1px dashed var(--accent);border-radius:8px;cursor:pointer;font-size:13px;">+ 구간 추가</button>
|
style="margin-top:8px;width:100%;padding:9px;background:transparent;color:var(--accent);border:1px dashed var(--accent);border-radius:8px;cursor:pointer;font-size:13px;">+ 구간 추가</button>
|
||||||
<div class="note" style="margin-top:8px;">여러 구간을 넣으면 순서대로 이어붙여 하나의 캡컷 드래프트로 만듭니다. 형식: 분:초(03:30) 또는 시:분:초(01:03:30)</div>
|
<div class="note" style="margin-top:8px;">여러 구간을 넣으면 순서대로 이어붙여 하나의 캡컷 드래프트로 만듭니다. 형식: 분:초(03:30) 또는 시:분:초(01:03:30)</div>
|
||||||
<button type="button" class="ghost" id="ytccBtn" style="margin-top:12px;">💬 구간 댓글 매칭 (h-lab · 선택사항)</button>
|
|
||||||
<div class="note" id="ytccNote" style="margin-top:4px;">구간을 언급한 댓글을 자동 선택하고, 나머지는 좋아요순으로 보여줍니다. 선택한 카드는 편집 시작 때 자동으로 들어갑니다(폴더 지정보다 우선).</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="ytccArea"></div>
|
<!-- 제목·출처 — 검토 영역 위. 분석에 시간이 걸리므로 분석 후 오타를 발견해도
|
||||||
|
재분석 없이 여기서 고치면 생성(드래프트 만들기)에 그대로 반영된다. -->
|
||||||
|
<div class="card" style="padding:18px;margin-top:10px;">
|
||||||
|
<div class="row2">
|
||||||
|
<div class="field"><label>제목 윗줄 · 주황색</label><input id="yttop" placeholder="서브제목" /></div>
|
||||||
|
<div class="field"><label>출처 (선택)</label><input id="ytchan" placeholder="유튜브면 채널명 자동" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label>제목 아랫줄 · 흰색</label><input id="ytmain" placeholder="메인제목" /></div>
|
||||||
|
<div class="note" style="margin-top:2px;">비워두면 그 텍스트는 안 들어가요. 분석이 끝난 뒤에도 고칠 수 있고, 드래프트를 만들 때 여기 값이 반영됩니다.</div>
|
||||||
|
</div>
|
||||||
|
<div id="ytSteps"></div>
|
||||||
|
<div id="ytLog"></div>
|
||||||
|
<div id="ytReview"></div>
|
||||||
|
<button class="run" id="ytBuild" style="display:none;margin-top:14px;">선택한 카드로 드래프트 만들기</button>
|
||||||
|
<div id="ytBoard" style="display:none;margin-top:10px;"></div>
|
||||||
|
<div id="ytSummary" class="card" style="display:none;padding:18px;margin-top:12px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 붙여넣기(JSON) 모드 -->
|
<!-- 붙여넣기(JSON) 모드 -->
|
||||||
@ -579,9 +592,10 @@ function setMode(m){
|
|||||||
$("#panel-yt").style.display=m==="yt"?"block":"none";
|
$("#panel-yt").style.display=m==="yt"?"block":"none";
|
||||||
$("#panel-paste").style.display=m==="paste"?"block":"none";
|
$("#panel-paste").style.display=m==="paste"?"block":"none";
|
||||||
$("#panel-auto").style.display=m==="auto"?"block":"none";
|
$("#panel-auto").style.display=m==="auto"?"block":"none";
|
||||||
// 붙여넣기·자동: 제목은 JSON에 있으니 숨김. 자동: 카드 폴더도 자동이라 숨김.
|
// 붙여넣기·자동: 제목은 JSON에 있으니 숨김. 구간: 검토 영역 위 전용 입력칸(#yttop 등)을 쓰므로 숨김.
|
||||||
|
// 자동: 카드 폴더도 자동이라 숨김.
|
||||||
$("#commonFields").style.display="grid";
|
$("#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";
|
$("#cdirField").style.display=m==="auto"?"none":"block";
|
||||||
$("#run").style.display=(m==="auto"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용
|
$("#run").style.display=(m==="auto"||m==="paste")?"none":"block"; // 자동·붙여넣기 탭은 자체 버튼 사용
|
||||||
// 붙여넣기 탭은 댓글 매칭을 위해 무음 제거가 항상 켜진다 — 끌 수 있는 것처럼 보이면 안 됨
|
// 붙여넣기 탭은 댓글 매칭을 위해 무음 제거가 항상 켜진다 — 끌 수 있는 것처럼 보이면 안 됨
|
||||||
@ -611,11 +625,12 @@ yt:`<h3>▶ 유튜브 구간 탭 사용법</h3>
|
|||||||
<li><b>유튜브 URL</b> 입력 (실제 열리는 주소)</li>
|
<li><b>유튜브 URL</b> 입력 (실제 열리는 주소)</li>
|
||||||
<li><b>구간 입력</b> — 시간형식 <code>분:초</code> 또는 <code>시:분:초</code>. 숫자만 쳐도 자동 변환(4314→43:14), 끝 비우면 시작+1분30초</li>
|
<li><b>구간 입력</b> — 시간형식 <code>분:초</code> 또는 <code>시:분:초</code>. 숫자만 쳐도 자동 변환(4314→43:14), 끝 비우면 시작+1분30초</li>
|
||||||
<li><b>+ 구간 추가</b>로 여러 구간 → 순서대로 이어붙여 하나의 드래프트</li>
|
<li><b>+ 구간 추가</b>로 여러 구간 → 순서대로 이어붙여 하나의 드래프트</li>
|
||||||
<li><b>편집 시작</b> → 다운로드·병합 → 무음 컷 + Whisper 자막 → CapCut</li>
|
<li><b>편집 시작</b> → 다운로드·병합·무음 컷·Whisper 자막 → h-lab 댓글 수집·구간별 추천</li>
|
||||||
|
<li>검토 화면에서 구간별 추천 댓글 카드를 확인·선택하고 <b>선택한 카드로 드래프트 만들기</b></li>
|
||||||
</ol>
|
</ol>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>출처</b>: 비우면 유튜브 채널명 자동(@채널)</li>
|
<li><b>제목·출처</b>: 검토 영역 위 입력칸 — 분석이 끝난 뒤 오타를 고쳐도 재분석 없이 드래프트에 반영됩니다. 출처를 비워두면 유튜브 채널명 자동(@채널)</li>
|
||||||
<li><b>댓글 카드</b>: 폴더 경로 지정 시 하단에 3초마다 1장 자동 삽입</li>
|
<li><b>댓글 카드</b>: 검토에서 하나도 안 고르면 예전처럼 <b>댓글 카드 폴더</b> 지정으로 동작(3초마다 1장)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="helpdim">긴 구간일수록 다운로드·자막(1분당 ≈30초) 시간이 늘어납니다.</div>`,
|
<div class="helpdim">긴 구간일수록 다운로드·자막(1분당 ≈30초) 시간이 늘어납니다.</div>`,
|
||||||
paste:`<h3>📋 붙여넣기 탭 사용법</h3>
|
paste:`<h3>📋 붙여넣기 탭 사용법</h3>
|
||||||
@ -731,6 +746,13 @@ runBtn.addEventListener("click",async()=>{
|
|||||||
window._runStart=Date.now(); // 시작~끝 총 시간 측정
|
window._runStart=Date.now(); // 시작~끝 총 시간 측정
|
||||||
logEl.innerHTML="";resultEl.style.display="none";resultEl.innerHTML="";
|
logEl.innerHTML="";resultEl.style.display="none";resultEl.innerHTML="";
|
||||||
stepsEl.style.display="none";stepsEl.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;
|
let res;
|
||||||
if(mode==="file"){
|
if(mode==="file"){
|
||||||
if(!picked){addLog("파일을 먼저 선택하세요.");return;}
|
if(!picked){addLog("파일을 먼저 선택하세요.");return;}
|
||||||
@ -738,35 +760,6 @@ runBtn.addEventListener("click",async()=>{
|
|||||||
const fd=new FormData();fd.append("file",picked);titleFields(fd);
|
const fd=new FormData();fd.append("file",picked);titleFields(fd);
|
||||||
try{res=await(await fetch("/upload",{method:"POST",body:fd})).json();}
|
try{res=await(await fetch("/upload",{method:"POST",body:fd})).json();}
|
||||||
catch(e){return fail("업로드 실패: "+e);}
|
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)으로 도므로 여기 안 옴.
|
// 📋 붙여넣기 탭은 자체 버튼(#pasteGo → #pasteBuild, auto.js)으로 도므로 여기 안 옴.
|
||||||
runBtn.textContent="처리 중…";
|
runBtn.textContent="처리 중…";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user