diff --git a/README.md b/README.md index dc3f17e..5b26cab 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,14 @@ h-lab 댓글 수집 → 컷별 댓글 카드 추천까지 자동으로 돌고, 화면 자막은 그대로 JSON의 `bottom`을 쓸지, Whisper 자동 자막으로 바꿀지 따로 고를 수 있습니다.) ### ▶ 유튜브 구간 -한 URL + 여러 구간(+ 구간 추가) → 이어붙여 **무음컷 + 자동 자막(Whisper)**. +한 URL + 여러 구간(+ 구간 추가) → 이어붙여 **무음컷(선택) + 자동 자막(Whisper)**. +무음 제거는 아래 **영상 옵션**의 체크박스로 켜고 끕니다(기본 켬). 📋 붙여넣기 탭과 같은 흐름 — 분석(다운로드·무음·받아쓰기)이 끝나면 h-lab 댓글을 구간별로 자동 추천해 검토 화면을 보여주고, 카드를 고른 뒤 드래프트를 만듭니다. ### 📁 파일 -로컬 영상 파일 → **무음컷 + 자동 자막**. (댓글 카드는 폴더 지정 방식만 — 검토 화면 없음) +로컬 영상 파일 → **무음컷(선택) + 자동 자막**. (댓글 카드는 폴더 지정 방식만 — 검토 화면 없음) +무음 제거는 아래 **영상 옵션**의 체크박스로 켜고 끕니다(기본 켬). 세 방법 모두 아래 **영상 옵션**을 함께 적용합니다. diff --git a/assets/frame_template.png b/assets/frame_template.png index 1134670..26a0b84 100644 Binary files a/assets/frame_template.png and b/assets/frame_template.png differ diff --git a/capcut_agent/pipeline.py b/capcut_agent/pipeline.py index bda5dbc..44b1101 100644 --- a/capcut_agent/pipeline.py +++ b/capcut_agent/pipeline.py @@ -202,11 +202,16 @@ BG_STEPS: List[Dict[str, str]] = [ ] -def bg_steps(youtube: Optional[dict]) -> List[Dict[str, str]]: - """배경템플릿 파이프라인의 manifest 스텝. 쪼갠 두 조각(bg_analyze/bg_draft)이 함께 내는 전체 목록.""" +def bg_steps(youtube: Optional[dict], remove_silence: bool = True) -> List[Dict[str, str]]: + """배경템플릿 파이프라인의 manifest 스텝. 쪼갠 두 조각(bg_analyze/bg_draft)이 함께 내는 전체 목록. + + remove_silence=False 면 silence 스텝을 아예 안 낸다 — bg_analyze 도 그 스텝을 + 내지 않으므로 manifest 에 남겨두면 화면에서 영원히 대기 상태로 보인다. + """ steps = ([{"id": "download", "label": "유튜브 여러 구간 다운로드·병합"}] if youtube else []) - return steps + [{"id": "silence", "label": "무음·발화 분석"}, - {"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"}, + if remove_silence: + steps.append({"id": "silence", "label": "무음·발화 분석"}) + return steps + [{"id": "asr", "label": "받아쓰기 (Gemini/Whisper)"}, {"id": "draft", "label": "템플릿 드래프트 생성"}] @@ -218,11 +223,16 @@ async def bg_analyze( title_main: str = "", channel: str = "", youtube: Optional[dict] = None, + remove_silence: bool = True, ) -> AsyncIterator[dict]: - """배경템플릿 파이프라인 앞부분: [유튜브 구간 다운로드] → probe → 무음컷 → 받아쓰기. + """배경템플릿 파이프라인 앞부분: [유튜브 구간 다운로드] → probe → [무음컷] → 받아쓰기. 마지막에 `bg_draft` 로 이어줄 `{"type": "state", ...}` 를 낸다. (오디오가 전부 무음이면 `error` 를 내고 조용히 끝난다 — 이때는 `state` 가 안 나온다.) + + remove_silence=False 면 무음 분석을 아예 안 돌리고 `keep = [(0, duration)]` 한 덩어리로 + 간다 → `cut_plan`·`_remap_placements` 가 항등 매핑이 돼 자막·카드 좌표가 그대로 남는다 + (붙여넣기 파이프라인 `paste_analyze` 와 같은 패턴). 이때 `places == raw_places`. """ t_all = time.perf_counter() use_gemini = has_gemini_key() @@ -265,23 +275,28 @@ async def bg_analyze( meta = await asyncio.to_thread(probe, video_path) yield {"type": "log", "msg": f"{meta.width}×{meta.height} · {meta.fps}fps · {meta.duration:.1f}s"} - # ── silence (오디오 무음 컷) ── - yield {"type": "step", "id": "silence", "status": "start"} - t = time.perf_counter() - keep = await asyncio.to_thread( - detect_speech_segments, video_path, meta.duration, - noise_db=-28.0, min_silence=0.3, pad=0.04, - ) - if not keep: - yield {"type": "error", "message": "오디오가 없거나 전부 무음입니다."} - return - kept = sum(e - s for s, e in keep) - # raw_places(병합본 좌표) → 압축 타임라인 좌표. 자막·배치·장수는 전부 이걸 쓴다. - places = _remap_placements(raw_places, keep) if raw_places else [] - await _floor(t) - yield {"type": "step", "id": "silence", "status": "done", - "elapsed": round(time.perf_counter() - t, 1), - "detail": f"보존 {len(keep)}구간 · {meta.duration - kept:.1f}s 무음 제거"} + # ── silence (오디오 무음 컷) — 선택. 끄면 통짜 한 덩어리(항등 매핑) ── + if remove_silence: + yield {"type": "step", "id": "silence", "status": "start"} + t = time.perf_counter() + keep = await asyncio.to_thread( + detect_speech_segments, video_path, meta.duration, + noise_db=-28.0, min_silence=0.3, pad=0.04, + ) + if not keep: + yield {"type": "error", "message": "오디오가 없거나 전부 무음입니다."} + return + kept = sum(e - s for s, e in keep) + # raw_places(병합본 좌표) → 압축 타임라인 좌표. 자막·배치·장수는 전부 이걸 쓴다. + places = _remap_placements(raw_places, keep) if raw_places else [] + await _floor(t) + yield {"type": "step", "id": "silence", "status": "done", + "elapsed": round(time.perf_counter() - t, 1), + "detail": f"보존 {len(keep)}구간 · {meta.duration - kept:.1f}s 무음 제거"} + else: + keep = [(0.0, meta.duration)] # 통짜 → cut_plan/_remap 이 항등 매핑 + places = list(raw_places) + yield {"type": "log", "msg": "무음 제거 꺼짐 — 구간을 그대로 이어붙입니다."} # ── asr (받아쓰기): 타이밍=Whisper 단어 타임스탬프(정확, 드리프트 없음). # 글자 품질만 Gemini로 제자리 교정(1:1, 시간은 절대 안 건드림) → 싱크 유지 ── @@ -410,16 +425,18 @@ async def process_bg_template( cards_fixed: bool = False, bg_white: bool = False, youtube: Optional[dict] = None, + remove_silence: bool = True, ) -> AsyncIterator[dict]: """배경템플릿 파이프라인 — 📁 파일 탭 / ▶ 유튜브 구간 탭용 얇은 래퍼. analyze/draft 두 조각을 연달아 부른다. `state` 이벤트는 밖으로 안 흘린다 (기존 UI가 모르는 타입이라 흘리면 로그에 정체불명 이벤트가 찍힌다). """ - yield {"type": "manifest", "steps": bg_steps(youtube)} + yield {"type": "manifest", "steps": bg_steps(youtube, remove_silence)} state = None async for ev in bg_analyze(video_path, draft_name, title_top=title_top, - title_main=title_main, channel=channel, youtube=youtube): + title_main=title_main, channel=channel, youtube=youtube, + remove_silence=remove_silence): if ev.get("type") == "state": state = ev["state"] continue diff --git a/server/app.py b/server/app.py index 74bf46f..4d65d4b 100644 --- a/server/app.py +++ b/server/app.py @@ -148,6 +148,7 @@ async def upload( scene: str = Form(""), comments_dir: str = Form(""), bg_white: str = Form(""), + remove_silence: str = Form("1"), ) -> JSONResponse: data = await file.read() # content hash → 같은 영상 재업로드 시 캐시/멱등 (mtime 아님) @@ -164,6 +165,7 @@ async def upload( "title_top": title_top, "title_main": title_main, "channel": channel, "video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene), "comments_dir": comments_dir, "bg_white": _truthy(bg_white), + "remove_silence": _truthy(remove_silence), } return JSONResponse({"job_id": h, "draft_name": JOBS[h]["draft_name"]}) @@ -262,6 +264,7 @@ async def stream(job_id: str) -> StreamingResponse: bg_white=job.get("bg_white", False), youtube=job.get("youtube"), cards_fixed=job.get("cards_fixed", False), + remove_silence=job.get("remove_silence", True), ) async for ev in stream_iter: yield _sse(ev) @@ -280,6 +283,7 @@ async def yt_analyze( title_top: str = Form(""), title_main: str = Form(""), channel: str = Form(""), + remove_silence: str = Form("1"), ) -> JSONResponse: """▶ 유튜브 구간 탭(새 흐름) 1단계 — URL·구간 검증 후 분석 예약. 실제 작업은 /yt/stream 에서. @@ -293,9 +297,11 @@ async def yt_analyze( rng = _parse_ranges(ranges) if not rng: return JSONResponse({"error": "구간을 하나 이상 입력하세요."}, 400) - aid = hashlib.sha1(("yt|" + u + "|" + ranges).encode()).hexdigest()[:12] + rs = _truthy(remove_silence) + aid = hashlib.sha1(("yt|" + u + "|" + ranges + "|rs" + ("1" if rs else "0")).encode() + ).hexdigest()[:12] ANALYSES[aid] = {"url": u, "ranges": rng, "title_top": title_top, - "title_main": title_main, "channel": channel} + "title_main": title_main, "channel": channel, "remove_silence": rs} return JSONResponse({"analysis_id": aid}) @@ -314,8 +320,9 @@ async def yt_stream(aid: str) -> StreamingResponse: warnings: list[str] = [] # draft 스텝은 이 단계(analyze)에서 안 돈다 — /yt/build 때 별도 스트림으로 실행되므로 # 여기 manifest 에 넣으면 영원히 start 가 안 와 화면에 대기 상태로 멈춰 보인다. + rs = a.get("remove_silence", True) yield _sse({"type": "manifest", "steps": - [s for s in bg_steps(youtube) if s["id"] != "draft"] + + [s for s in bg_steps(youtube, rs) if s["id"] != "draft"] + [{"id": "comments", "label": "댓글 수집 (h-lab)"}, {"id": "recommend", "label": "컷별 댓글 추천"}]}) com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url)) @@ -326,7 +333,7 @@ async def yt_stream(aid: str) -> StreamingResponse: title_top=a.get("title_top", ""), title_main=a.get("title_main", ""), channel=a.get("channel", ""), - youtube=youtube): + youtube=youtube, remove_silence=rs): if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다 state = ev["state"] continue @@ -357,8 +364,9 @@ async def yt_stream(aid: str) -> StreamingResponse: yield _sse({"type": "step", "id": "recommend", "status": "start"}) # ⚠ 좌표계 둘: places/captions = 압축 타임라인(카드·자막 추출용), # ranges_sec = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다. - # bg_analyze 의 무음 제거는 항상 켜져 있어 state["places"] 는 이미 압축 좌표다 - # (_remap_placements 를 여기서 또 부르면 두 번 압축된다 — 부르지 않는다). + # state["places"] 는 bg_analyze 가 이미 최종 타임라인 좌표로 만들어 둔 것이다 + # (무음 제거 켬 = 압축 좌표 / 끔 = raw_places 그대로). 여기서 _remap_placements 를 + # 또 부르면 두 번 압축된다 — 부르지 않는다. places = state["places"] orig = state["ranges_sec"] try: @@ -449,7 +457,8 @@ async def yt_build( with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out: out.write(body) - # ⚠ state["places"] 는 bg_analyze 가 이미 무음 제거를 반영해 압축한 좌표다. + # ⚠ state["places"] 는 bg_analyze 가 이미 최종 타임라인 좌표로 만든 것이다 + # (무음 제거 켬 = 압축 좌표 / 끔 = 원본 그대로). # 여기서 또 재매핑하지 않는다 — 카드 시간은 이 좌표를 그대로 컷 구간으로 쓴다. cut_cards = _cards_by_cut(_card_paths(cdir), cut_map, state["places"], state["total"], fixed=_truthy(cards_fixed)) @@ -723,10 +732,13 @@ def _hl_paste_payload(paste: dict) -> dict: @app.post("/auto/prepare") -async def auto_prepare(aid: str = Form(...), ids: str = Form(...)) -> JSONResponse: +async def auto_prepare(aid: str = Form(...), ids: str = Form(...), + remove_silence: str = Form("1")) -> JSONResponse: """자동 탭 2단계 — 검토 화면에서 제외하지 않은 ID만 준비 예약. 실제 작업은 /auto/prepare/{pid} 에서. ids: 남길 하이라이트 id의 JSON 배열(예: [1,2,4]) — ✕ 로 제외된 ID는 여기 안 들어온다. + remove_silence: 무음 제거 여부(기본 켬). 받아쓰기(asr)와 달리 댓글 매칭에 필수가 아니다 — + 매칭은 원본 시각(orig) 기준이고 paste_analyze 가 False 면 항등 매핑으로 돈다. """ a = ANALYSES.get(aid) if not a or not a.get("highlights"): @@ -737,8 +749,9 @@ async def auto_prepare(aid: str = Form(...), ids: str = Form(...)) -> JSONRespon raise ValueError except (json.JSONDecodeError, ValueError): return JSONResponse({"error": "준비할 ID 목록이 올바르지 않습니다."}, 400) - pid = hashlib.sha1((aid + "|" + ids).encode()).hexdigest()[:12] - PREPARES[pid] = {"aid": aid, "ids": id_list} + rs = _truthy(remove_silence) + pid = hashlib.sha1((aid + "|" + ids + "|rs" + ("1" if rs else "0")).encode()).hexdigest()[:12] + PREPARES[pid] = {"aid": aid, "ids": id_list, "remove_silence": rs} return JSONResponse({"prepare_id": pid}) @@ -803,7 +816,8 @@ async def auto_prepare_stream(pid: str) -> StreamingResponse: payload = _hl_paste_payload(h["paste"]) # dict 컷 → 튜플 컷 (필수 — docstring 참고) try: async for ev in paste_analyze(payload, f"auto_{aid}_{hid}", - remove_silence=True, asr_bottom=True, + remove_silence=p.get("remove_silence", True), + asr_bottom=True, name_suffix=safe_tag): if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다 state = ev["state"] diff --git a/server/static/auto.js b/server/static/auto.js index e00b5b3..fae237e 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -526,14 +526,15 @@ function curMode(){ const r=document.querySelector('input[name="amode"]:checked'); return r?r.value:"full"; } -// ⚠ 네 모드 전부 /auto/prepare 가 댓글 매칭을 위해 remove_silence=True·asr_bottom=True 로 -// 고정해서 돈다(공통 옵션의 "무음 제거" 체크박스는 자동 탭에서 숨겨져 있다 — setMode() 참조). -// "무음 제거는 아래 공통 옵션을 따른다"는 예전 문구가 이 사실과 어긋나 오해를 낳았었다. +// ⚠ 네 모드 전부 /auto/prepare 가 댓글 매칭을 위해 asr_bottom=True 로 고정해서 돈다. +// 무음 제거는 패널의 #autoRmsilence 체크박스(기본 켬)를 폼으로 넘겨 선택 가능 — +// 댓글 매칭은 원본 시각(orig) 기준이라 무음 제거를 꺼도 어긋나지 않는다. +// (공통 옵션의 "무음 제거" 체크박스는 자동 탭에서 여전히 숨김 — setMode() 참조.) const MODE_HELP={ - full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).", - whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).", - wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만듭니다. JSON에 title_top·title_main이 있으면 제목(윗줄·아랫줄)이 자동으로 채워지고, 검토 화면에서 ID별로 고칠 수 있습니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).", - paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 무음 제거·받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용됩니다(끌 수 없음).", + full:"Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 그 구간을 언급한 댓글을 찾아옵니다. 받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", + whole:"Gemini가 구간 5개만 고르고(Step1), 각 구간을 컷 편집 없이 통짜로 만듭니다. 받아쓰기(Whisper)는 댓글 매칭을 위해 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", + wpaste:"Gemini를 쓰지 않습니다. 구간 JSON(candidates 5개)을 붙여넣으면 그 구간을 그대로 통짜로 만들고, 제목은 JSON의 title_top·title_main을 그대로 씁니다. 1차 검토 없이 바로 다운로드·받아쓰기·댓글 매칭까지 진행되고 댓글 선택 화면으로 갑니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", + paste:"Gemini를 쓰지 않습니다. 오팔에서 받은 JSON 5개를 통째로 붙여넣으면 댓글 선택 화면으로 갑니다. URL을 비우면 JSON 안의 url을 씁니다. 받아쓰기(Whisper)는 항상 적용되고, 무음 제거는 위 체크박스로 켜고 끕니다.", }; const PASTE_UI={ paste:{label:"오팔 JSON 붙여넣기 (여러 개를 통째로 — 사이에 구분선·타이틀 후보가 섞여 있어도 됨)", @@ -553,8 +554,12 @@ function applyMode(){ } $("#autoUrlNote").textContent=p?p.note:""; $("#autoModeHelp").textContent=MODE_HELP[m]; - $("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)"; + $("#autoGo").textContent=(p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)")+ + (autoRunOn()?" → 영상까지 자동":""); } +/* "끝까지 진행" — 검토 화면 두 곳(1차 제목 선택 · 2차 댓글 선택)에서 멈추지 않고 + 기본값·추천 그대로 다음 단계로 넘어간다. 화면 편의 기능이라 서버는 이 상태를 모른다. */ +function autoRunOn(){const el=$("#autoAutoRun");return !!(el&&el.checked);} /* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */ async function analyze(){ @@ -591,7 +596,15 @@ async function analyze(){ if(ev.type==="manifest") renderASteps(ev.steps); else if(ev.type==="step") updateAStep(ev); else if(ev.type==="log") alog(ev.msg); - else if(ev.type==="result"){es.close();onResult(ev);doneA();} + else if(ev.type==="result"){es.close();onResult(ev);doneA(); + // 구간 JSON 모드: 제목이 JSON에 이미 있어 1차 검토(제목 선택)가 무의미 → + // 유효 구간이 있으면(autoPrepGo 표시 = ok>0) 바로 준비(다운로드·받아쓰기·댓글 매칭) 시작. + // "끝까지 진행"이 켜져 있으면 나머지 모드도 제목 기본값(첫 후보)으로 그냥 넘어간다. + if((m==="wpaste"||autoRunOn())&&$("#autoPrepGo").style.display==="block"){ + alog(m==="wpaste"?"구간 JSON 모드 — 제목이 JSON에 있으므로 바로 준비를 시작합니다." + :"끝까지 진행 — 제목은 기본 후보로 두고 바로 준비를 시작합니다."); + prepareAll(); + }} else if(ev.type==="error"){es.close();failA(ev.message);} }; es.onerror=()=>{es.close();failA("연결이 끊겼습니다.");}; @@ -707,6 +720,8 @@ async function prepareAll(){ const fd=new FormData(); fd.append("aid",AUTO_AID); fd.append("ids",JSON.stringify(ids)); + const rs=$("#autoRmsilence"); + fd.append("remove_silence",(!rs||rs.checked)?"1":""); // 체크박스 없으면 기본 켬 res=await(await fetch("/auto/prepare",{method:"POST",body:fd})).json(); }catch(e){return prepFail("요청 실패: "+e);} if(res.error) return prepFail(res.error); @@ -820,6 +835,13 @@ function onPrepareResult(ev){ $("#autoBuild").style.display="block"; updateBuildBtn(); if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex"; + // "끝까지 진행" — 댓글 선택 화면에서 기다리지 않고, 위에서 자동 선택해 둔 컷별 추천을 + // 그대로 확정해 바로 빌드. setTimeout(0)으로 이 SSE 핸들러를 끝낸 뒤 시작해야 + // buildAll 의 캡처가 방금 그린 DOM의 레이아웃 확정 후에 돈다. + if(autoRunOn()&&!$("#autoBuild").disabled){ + prepLog("끝까지 진행 — 추천 댓글을 그대로 쓰고 바로 영상 생성으로 넘어갑니다."); + setTimeout(buildAll,0); + } } /* ── 캡처 + 순차 빌드 ── */ @@ -1070,6 +1092,9 @@ function ytAnalyze(){ fd.append("title_top",$("#yttop").value); fd.append("title_main",$("#ytmain").value); fd.append("channel",$("#ytchan").value); + // 무음 제거는 공통 옵션의 #rmsilence 체크박스(구간 탭에서 보임)를 그대로 넘긴다. + // 예전엔 이 값을 아무도 안 읽어서 체크를 꺼도 bg_analyze 가 무조건 무음을 잘랐다. + fd.append("remove_silence",$("#rmsilence")&&!$("#rmsilence").checked?"0":"1"); 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; @@ -1321,6 +1346,7 @@ document.addEventListener("DOMContentLoaded",()=>{ $("#autoUrl").addEventListener("keydown",(e)=>{if(e.key==="Enter")analyze();}); document.querySelectorAll('input[name="amode"]').forEach(r=> r.addEventListener("change",applyMode)); + if($("#autoAutoRun")) $("#autoAutoRun").addEventListener("change",applyMode); applyMode(); $("#autoPrepGo").addEventListener("click",prepareAll); $("#autoBuild").addEventListener("click",buildAll); @@ -1332,12 +1358,15 @@ document.addEventListener("DOMContentLoaded",()=>{ if($("#pasteBuild")) $("#pasteBuild").addEventListener("click",pasteBuildAll); }); -/* ── 옵션 기억 — 배경 흰색 체크를 localStorage 에 저장, 다음 방문에도 유지. +/* ── 옵션 기억 — 체크 상태를 localStorage 에 저장, 다음 방문에도 유지. 서버는 이 상태를 모른다(폼 전송 값만 봄) — 순수 화면 편의 기능. */ (function(){ - const bw=$("#bgwhite"); if(!bw) return; - const saved=localStorage.getItem("opt_bgwhite"); - if(saved!==null) bw.checked=saved==="1"; - bw.addEventListener("change",()=>localStorage.setItem("opt_bgwhite",bw.checked?"1":"0")); + for(const [id,key] of [["#bgwhite","opt_bgwhite"],["#autoRmsilence","opt_auto_rmsilence"], + ["#autoAutoRun","opt_auto_autorun"]]){ + const el=$(id); if(!el) continue; + const saved=localStorage.getItem(key); + if(saved!==null) el.checked=saved==="1"; + el.addEventListener("change",()=>localStorage.setItem(key,el.checked?"1":"0")); + } })(); })(); diff --git a/server/static/index.html b/server/static/index.html index 2925048..26a6a79 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -464,6 +464,18 @@ 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:12px;line-height:1.5;resize:vertical;" placeholder='오팔 Step 3 결과(JSON 코드블록) 5개를 순서대로 전부 붙여넣으세요. 블록 ②·③ 텍스트가 섞여 들어와도 자동으로 JSON만 골라냅니다.'> +
+ +
받아쓰기(Whisper)·댓글 매칭은 항상 돌지만, 무음 제거는 꺼도 매칭이 어긋나지 않습니다.
+
+
+ +
켜면 중간에 멈추지 않습니다 — 제목은 기본값, 댓글은 컷별 추천 그대로 쓰고 바로 드래프트를 만듭니다. 끄면 지금처럼 댓글 선택 화면에서 기다립니다.
+
Gemini가 영상에서 하이라이트 구간 5개를 골라 편집안을 만들고, h-lab에서 @@ -549,8 +561,9 @@
+
끄면 구간을 자른 그대로 이어붙입니다(받아쓰기·자막은 그대로 돕니다).