자동 탭: 분석→준비→검토 3단계로 쪼개 받아쓰기를 제외 선택 뒤로 미룸

검토 화면의 ✕ 제외 버튼 때문에, 받아쓰기(다운로드·Whisper)를 제외 선택 전에
돌리면 CPU를 낭비한다. /auto/stream은 이제 편집안·타이틀 후보(highlights)만
내고 댓글 매칭을 하지 않는다. 새 /auto/prepare(POST)+/auto/prepare/{pid}(SSE)가
제외 안 한 ID만 순차로 다운로드·받아쓰기 + h-lab 댓글 매칭을 돌려 PSTATES에
담고, /auto/build는 그 상태로 paste_draft만 실행한다(다운로드·받아쓰기 재실행
없음). 화면은 1차 검토(하이라이트 카드: 제목 선택·제외·편집안 접기)와 2차
검토(기존 컷별 댓글 선택 화면, renderCutPanel 등 캡처 루프 그대로 재사용)로
분리했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-05 12:33:47 +09:00
parent 3c013dcc88
commit e650cfb992
3 changed files with 403 additions and 210 deletions

View File

@ -57,6 +57,12 @@ PSTATES: dict[str, dict] = {}
# /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요). # /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요).
YSTATES: dict[str, dict] = {} YSTATES: dict[str, dict] = {}
# 🤖 자동 탭 2단계(준비) — prepare_id → {"aid", "ids"}
# /auto/prepare(POST) 가 채우고 /auto/prepare/{pid}(SSE) 가 꺼내 쓴다.
# 준비된 개별 편집안의 다운로드·받아쓰기 상태는 PSTATES[f"{aid}:{id}"] 에 담긴다
# (📋 붙여넣기 탭과 같은 저장소를 공유 — /auto/build 가 그 값으로 paste_draft 만 돌린다).
PREPARES: dict[str, dict] = {}
_DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드") _DEFAULT_CDIR = os.path.join(os.path.dirname(BASE_DIR), "댓글카드")
@ -472,7 +478,13 @@ async def auto_analyze(url: str = Form(""), mode: str = Form("full"),
@app.get("/auto/stream/{aid}") @app.get("/auto/stream/{aid}")
async def auto_stream(aid: str) -> StreamingResponse: async def auto_stream(aid: str) -> StreamingResponse:
"""자동 탭 분석 SSE: Step1 → (Step3 ×N ∥ 댓글) → result.""" """자동 탭 1단계 분석 SSE: Step1 → (Step3 ×N) → result(편집안·타이틀 후보만).
댓글 매칭은 여기서 한다(Task 7) 검토 화면의 제외 뒤에 받아쓰기를 돌려야
낭비가 없으므로, 다운로드·받아쓰기·댓글 매칭은 전부 /auto/prepare 미뤘다.
`highlights` 여기서 만든 `paste`(편집안 JSON)·`titles`(제목 후보)까지만 담고,
/auto/prepare 그대로 꺼내 있게 `ANALYSES[aid]` 에도 남겨 둔다.
"""
a = ANALYSES.get(aid) a = ANALYSES.get(aid)
async def gen(): async def gen():
@ -481,7 +493,6 @@ async def auto_stream(aid: str) -> StreamingResponse:
return return
url = a["url"] url = a["url"]
mode = a.get("mode", "full") mode = a.get("mode", "full")
warnings: list[str] = []
highlights: list[dict] = [] highlights: list[dict] = []
def _need(total: float) -> int: def _need(total: float) -> int:
@ -511,8 +522,6 @@ async def auto_stream(aid: str) -> StreamingResponse:
# ── 오팔 JSON 여러 개 — Gemini 안 씀 ── # ── 오팔 JSON 여러 개 — Gemini 안 씀 ──
yield _sse({"type": "manifest", "steps": [ yield _sse({"type": "manifest", "steps": [
{"id": "parse", "label": "오팔 JSON 파싱"}, {"id": "parse", "label": "오팔 JSON 파싱"},
{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"},
]}) ]})
yield _sse({"type": "step", "id": "parse", "status": "start"}) yield _sse({"type": "step", "id": "parse", "status": "start"})
import re as _re import re as _re
@ -579,14 +588,10 @@ async def auto_stream(aid: str) -> StreamingResponse:
url = best_url url = best_url
yield _sse({"type": "step", "id": "parse", "status": "done", yield _sse({"type": "step", "id": "parse", "status": "done",
"detail": f"{len(highlights)}개 편집안"}) "detail": f"{len(highlights)}개 편집안"})
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
yield _sse({"type": "step", "id": "comments", "status": "start"})
elif mode == "wpaste": elif mode == "wpaste":
# ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ── # ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ──
yield _sse({"type": "manifest", "steps": [ yield _sse({"type": "manifest", "steps": [
{"id": "parse", "label": "구간 JSON 파싱"}, {"id": "parse", "label": "구간 JSON 파싱"},
{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "recommend", "label": "컷별 댓글 추천"},
]}) ]})
yield _sse({"type": "step", "id": "parse", "status": "start"}) yield _sse({"type": "step", "id": "parse", "status": "start"})
try: try:
@ -597,24 +602,16 @@ async def auto_stream(aid: str) -> StreamingResponse:
highlights.extend(_whole_hl(c) for c in cands) highlights.extend(_whole_hl(c) for c in cands)
yield _sse({"type": "step", "id": "parse", "status": "done", yield _sse({"type": "step", "id": "parse", "status": "done",
"detail": f"{len(highlights)}개 구간"}) "detail": f"{len(highlights)}개 구간"})
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
yield _sse({"type": "step", "id": "comments", "status": "start"})
else: else:
steps = [{"id": "step1", "label": "하이라이트 구간 선정 (Gemini)"}] steps = [{"id": "step1", "label": "하이라이트 구간 선정 (Gemini)"}]
if mode == "full": if mode == "full":
steps.append({"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"}) steps.append({"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"})
steps.append({"id": "comments", "label": "댓글 수집 (h-lab)"})
steps.append({"id": "recommend", "label": "컷별 댓글 추천"})
yield _sse({"type": "manifest", "steps": steps}) yield _sse({"type": "manifest", "steps": steps})
# 댓글은 URL을 이미 아니까 Step1 과 동시에 수집
com_task = asyncio.create_task(asyncio.to_thread(hlab.fetch_comments, url))
yield _sse({"type": "step", "id": "comments", "status": "start"})
# ── Step 1 ── # ── Step 1 ──
yield _sse({"type": "step", "id": "step1", "status": "start"}) yield _sse({"type": "step", "id": "step1", "status": "start"})
try: try:
cands = await asyncio.to_thread(autoplan.select_highlights, url) cands = await asyncio.to_thread(autoplan.select_highlights, url)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
com_task.cancel()
yield _sse({"type": "error", yield _sse({"type": "error",
"message": f"Step 1 실패 — {type(exc).__name__}: {exc}\n" "message": f"Step 1 실패 — {type(exc).__name__}: {exc}\n"
"오팔 → 📋 오팔 JSON 방식으로도 만들 수 있습니다."}) "오팔 → 📋 오팔 JSON 방식으로도 만들 수 있습니다."})
@ -673,58 +670,143 @@ async def auto_stream(aid: str) -> StreamingResponse:
yield _sse({"type": "step", "id": "step3", "status": "done", yield _sse({"type": "step", "id": "step3", "status": "done",
"detail": f"{ok}/{len(highlights)}개 성공"}) "detail": f"{ok}/{len(highlights)}개 성공"})
# 댓글 매칭·다운로드·받아쓰기는 여기서 안 한다(Task 7) — /auto/prepare 가
# 검토 화면에서 제외(✕)하지 않은 ID만 이어받아 돌린다. url 은 paste/wpaste 모드에서
# best_url 로 바뀌었을 수 있어(오팔 JSON 안 url) 여기서 다시 저장해 둔다.
a["highlights"] = highlights
a["url"] = url
yield _sse({"type": "result", "highlights": highlights})
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@app.post("/auto/prepare")
async def auto_prepare(aid: str = Form(...), ids: str = Form(...)) -> JSONResponse:
"""자동 탭 2단계 — 검토 화면에서 제외하지 않은 ID만 준비 예약. 실제 작업은 /auto/prepare/{pid} 에서.
ids: 남길 하이라이트 id의 JSON 배열(: [1,2,4]) 제외된 ID는 여기 들어온다.
"""
a = ANALYSES.get(aid)
if not a or not a.get("highlights"):
return JSONResponse({"error": "분석 결과가 만료됐습니다. 다시 분석해 주세요."}, 404)
try:
id_list = json.loads(ids)
if not isinstance(id_list, list) or not id_list:
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}
return JSONResponse({"prepare_id": pid})
@app.get("/auto/prepare/{pid}")
async def auto_prepare_stream(pid: str) -> StreamingResponse:
"""자동 탭 2단계 SSE — 남은 ID만 순차로 다운로드·받아쓰기 + 댓글 매칭.
순차로 돌린다(전역 방침) yt-dlp·ffmpeg·Whisper CPU 써서 동시에 여러 개를
돌리면 서로 느려지기만 한다. ID 하나 실패는 ID만 건너뛰고 나머지는 계속 진행한다
(다운로드 실패 말고는 드래프트 생성을 막지 않는다는 전역 원칙과 같은 이유).
"""
p = PREPARES.get(pid)
async def gen():
if not p:
yield _sse({"type": "error", "message": "알 수 없는 준비 요청입니다."})
return
aid = p["aid"]
a = ANALYSES.get(aid)
if not a or not a.get("highlights"):
yield _sse({"type": "error", "message": "분석 결과가 만료됐습니다. 다시 분석해 주세요."})
return
by_id = {h["id"]: h for h in a["highlights"] if "paste" in h}
targets = [by_id[i] for i in p["ids"] if i in by_id]
if not targets:
yield _sse({"type": "error", "message": "준비할 편집안이 없습니다."})
return
url = a.get("url", "")
warnings: list[str] = []
yield _sse({"type": "manifest", "steps": [
{"id": "comments", "label": "댓글 수집 (h-lab)"},
{"id": "prepare", "label": "ID별 순차 준비 (다운로드·받아쓰기)"},
]})
yield _sse({"type": "step", "id": "comments", "status": "start"})
comments: list[dict] = [] comments: list[dict] = []
try: try:
comments = await com_task comments = await asyncio.to_thread(hlab.fetch_comments, url)
yield _sse({"type": "step", "id": "comments", "status": "done", yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": f"{len(comments)}"}) "detail": f"{len(comments)}"})
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001 — 댓글 실패는 진행을 막지 않는다
warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})") warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})")
yield _sse({"type": "step", "id": "comments", "status": "done", yield _sse({"type": "step", "id": "comments", "status": "done",
"detail": "실패(생략)"}) "detail": "실패(생략)"})
# 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다. yield _sse({"type": "step", "id": "prepare", "status": "start"})
# 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은 highlights_out: list[dict] = []
# 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게. all_ranges: list[tuple] = []
# # ⚠ 순차로 돌려라 — yt-dlp·ffmpeg·Whisper 가 CPU 를 다 쓴다(전역 방침).
# 하이라이트마다 Gemini 를 순차로 부른다(동시 호출은 429 를 부른다 — Step 3 도 같은 이유로 for h in targets:
# 시차 재시도를 쓴다). 최악 5×90초라 진행 표시가 없으면 사용자가 멈춘 줄 알고 새로고침해 hid = h["id"]
# 분석이 통째로 날아간다 → 스텝 + 하이라이트별 로그를 반드시 흘린다. state = None
no_ts = [c for c in comments if not c["times"]] failed = False
targets = [h for h in highlights if "paste" in h] try:
yield _sse({"type": "step", "id": "recommend", "status": "start"}) async for ev in paste_analyze(h["paste"], f"auto_{aid}_{hid}",
t_rec = time.perf_counter() remove_silence=True, asr_bottom=True):
for n, h in enumerate(targets, 1): if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다
matched = hlab.match_window(comments, h["start"], h["end"]) state = ev["state"]
h["matched"] = matched continue
h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts)) if ev.get("type") == "log":
yield _sse({"type": "log", yield _sse({"type": "log", "msg": f"ID {hid}: {ev.get('msg')}"})
"msg": f"ID {h.get('id')} 컷별 댓글 추천 중… " continue
f"({n}/{len(targets)}, 컷 " yield _sse(ev) # step(download/asr) — manifest 밖 id 는 화면에서 무시됨
f"{len(h['paste'].get('cuts') or [])}개)"}) except Exception as exc: # noqa: BLE001 — 이 ID만 건너뛰고 나머지는 계속
# 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다. failed = True
# Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다. warnings.append(f"ID {hid} 준비 실패 — {type(exc).__name__}: {exc}")
yield _sse({"type": "log", "msg": f"ID {hid} 실패: {type(exc).__name__}: {exc}"})
if failed or state is None:
if not failed:
warnings.append(f"ID {hid} 준비 실패 — 분석 상태를 만들지 못했습니다")
continue
# ⚠ 좌표계 둘: places = 압축 타임라인(카드·자막 추출용),
# orig = 원본 영상 시각(⭐ 분:초 매칭용). 섞으면 카드가 통째로 어긋난다.
places = state["card_places"]
orig = [(s, e) for s, e, _, _ in state["cuts"]]
all_ranges.extend(orig)
try: try:
cuts, need, ai_failed = await asyncio.to_thread( cuts, need, ai_failed = await asyncio.to_thread(
recommend.build_highlight_cuts, h, comments) recommend.cuts_from_state, places, orig, state["bottom_caps"], comments)
if cuts:
h["cuts"], h["need"] = cuts, need
if ai_failed: if ai_failed:
# cuts 는 분:초·단어겹침·좋아요만으로 채워져 비지 않는다 — 이 신호가 없으면 warnings.append(f"ID {hid} AI 추천 실패(Gemini 응답 없음) — "
# 429·503(재시도까지 소진)이 조용히 삼켜져 아무도 모른다. "무엇이 안 됐고
# 그래서 어떻게 됐는지"를 한 문장에 담는다(사용자가 로그 영역만 보고 지나침).
warnings.append(
f"ID {h.get('id')} AI 추천 실패(Gemini 응답 없음) — "
"분:초·자막단어·좋아요로 배정했습니다") "분:초·자막단어·좋아요로 배정했습니다")
except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다 except Exception as exc: # noqa: BLE001
warnings.append( cuts, need = None, max(1, int(state["timeline_dur"] // 3))
f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 " warnings.append(f"ID {hid} 컷별 추천 실패 — 컷 정보 없이 진행합니다 "
f"({type(exc).__name__}: {exc})") f"({type(exc).__name__}: {exc})")
yield _sse({"type": "step", "id": "recommend", "status": "done",
"elapsed": round(time.perf_counter() - t_rec, 1), PSTATES[f"{aid}:{hid}"] = {"state": state, "places": places, "orig": orig,
"detail": f"{len(targets)}개 하이라이트"}) "payload": h["paste"]}
yield _sse({"type": "result", "highlights": highlights, matched = hlab.match_ranges(comments, orig) if comments else []
"comments": comments, "warnings": warnings}) highlights_out.append({
"id": hid, "cuts": cuts, "need": need,
"cutRanges": [{"start": s, "end": e} for s, e in orig],
"matched": matched,
})
yield _sse({"type": "log", "msg": f"ID {hid} 준비 완료"})
yield _sse({"type": "step", "id": "prepare", "status": "done",
"detail": f"{len(highlights_out)}/{len(targets)}개 완료"})
no_ts = [c for c in comments if not c["times"]]
matched_all = hlab.match_ranges(comments, all_ranges) if all_ranges else []
yield _sse({"type": "result", "highlights": highlights_out,
"comments": comments,
"candidates": hlab.top_liked(no_ts, set(matched_all), len(no_ts)),
"warnings": warnings})
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",
@ -781,32 +863,57 @@ async def prompts_post(step1: str = Form(None), step3: str = Form(None),
@app.post("/auto/build") @app.post("/auto/build")
async def auto_build( async def auto_build(
data: str = Form(...), aid: str = Form(...),
tag: str = Form(""), hid: str = Form(..., alias="id"),
title_top: str = Form(""),
title_main: str = Form(""),
cards: list[UploadFile] = File(default=[]), cards: list[UploadFile] = File(default=[]),
card_cuts: str = Form(""),
video_scale: str = Form("144"), video_scale: str = Form("144"),
flip: str = Form(""), flip: str = Form(""),
scene: str = Form(""), scene: str = Form(""),
bg_white: str = Form(""), bg_white: str = Form(""),
remove_silence: str = Form(""),
asr_bottom: str = Form(""),
cards_fixed: str = Form(""), cards_fixed: str = Form(""),
card_cuts: str = Form(""), asr_bottom: str = Form("1"),
comments_dir: str = Form(""),
) -> JSONResponse: ) -> JSONResponse:
"""자동 탭 빌드 — 붙여넣기 스키마 JSON + 카드 PNG들 → 기존 paste job. """자동 탭 3단계 — /auto/prepare 가 PSTATES 에 보관한 상태로 드래프트만 만든다.
tag(: '하이라이트1') 드래프트 이름 꼬리표 같은 영상 5개가 서로 다운로드·받아쓰기는 다시 하지 않는다(/paste/build 같은 방식) aid+id
덮어쓰는 것을 막는다. 진행은 기존 /stream/{job_id} 본다. PSTATES[f"{aid}:{id}"] 찾아 paste_draft 돌린다. title_top/title_main
검토 화면(1)에서 고른 값을 여기서 받아 state 덮어쓴다.
""" """
key = f"{aid}:{hid}"
st = PSTATES.get(key)
if not st:
return JSONResponse({"error": "준비 결과가 만료됐습니다. 다시 준비해 주세요."}, 404)
state = st["state"]
state["title_top"] = title_top
state["title_main"] = title_main
# asr_bottom을 끈 경우 — 화면 자막을 받아쓰기 결과가 아니라 원래 JSON bottom으로
# 되돌린다(/paste/build 와 동일 규칙 — state["bottom_caps"]는 /auto/prepare 가
# 항상 asr_bottom=True 로 돈 결과라 받아쓰기로 덮여 있다).
if not _truthy(asr_bottom):
dur = state["dur"]
bottom_caps = [(p0, min(p1, dur), b) for (p0, p1), (_, _, b, _)
in zip(state["placements"], state["cuts"]) if b and p0 < dur]
bottom_caps = _remap_caps(bottom_caps, state["video_clips"])
state = {**state, "bottom_caps": bottom_caps}
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 전체 균등 배치로 폴백)
cut_map: list[int] = []
try: try:
payload = parse_paste(data) parsed = json.loads(card_cuts) if card_cuts.strip() else []
except ValueError as e: if isinstance(parsed, list):
return JSONResponse({"error": str(e)}, 400) cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
safe_tag = "".join(ch for ch in tag if ch.isalnum() or ch in "-_")[:20] except (json.JSONDecodeError, ValueError, TypeError):
sig = (payload["url"] + "|" + safe_tag + "|" cut_map = []
+ "|".join(f"{s:.3f}-{e:.3f}" for s, e, _, _ in payload["cuts"]))
sig = (key + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|"
+ bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards)))
h = hashlib.sha1(sig.encode()).hexdigest()[:12] h = hashlib.sha1(sig.encode()).hexdigest()[:12]
cdir = "" 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): # 재빌드 시 이전 카드 잔재 제거
@ -816,25 +923,13 @@ async def auto_build(
body = await f.read() body = await f.read()
with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out: with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out:
out.write(body) out.write(body)
# 카드별 소속 컷 — 값이 깨져도 빌드를 막지 않는다(없으면 기존 전체 균등 배치)
cut_map: list[int] = []
try:
parsed = json.loads(card_cuts) if card_cuts.strip() else []
if isinstance(parsed, list):
cut_map = [int(v) for v in parsed if isinstance(v, int) and not isinstance(v, bool)]
except (json.JSONDecodeError, ValueError, TypeError):
cut_map = []
JOBS[h] = { JOBS[h] = {
"paste": payload, "draft_name": f"auto_{h}", "paste_state": state, "card_cuts": cut_map,
"video_scale": _scale(video_scale), "flip": _truthy(flip), "video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene),
"scene": _truthy(scene), "comments_dir": cdir, "comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed),
"bg_white": _truthy(bg_white), "remove_silence": _truthy(remove_silence),
"asr_bottom": _truthy(asr_bottom), "name_suffix": safe_tag,
"cards_fixed": _truthy(cards_fixed),
"card_cuts": cut_map,
} }
return JSONResponse({"job_id": h, "cuts": len(payload["cuts"]), return JSONResponse({"job_id": h})
"cards": len(cards)})
@app.post("/paste/analyze") @app.post("/paste/analyze")

View File

@ -2,11 +2,15 @@
카드 DOM·캡처 방식은 h-lab comment-cards 동일 계열(modern-screenshot, 4). */ 카드 DOM·캡처 방식은 h-lab comment-cards 동일 계열(modern-screenshot, 4). */
(function(){ (function(){
const $=(s)=>document.querySelector(s); const $=(s)=>document.querySelector(s);
let A=null; // 분석 result {highlights, comments, warnings} let A=null; // 1차 결과(분석) {highlights} — 편집안·타이틀 후보만, 댓글 없음
let P=null; // 2차 결과(준비) {highlights:[{id,cuts,need,cutRanges,matched}],comments,candidates,warnings}
let AUTO_AID=null; // /auto/analyze 가 돌려준 analysis_id — /auto/prepare 에 넘긴다
let PREP_ID=null; // /auto/prepare 가 돌려준 prepare_id — SSE 구독용
let TITLE_PICKS={}; // hl.id → {top,main} — "준비 시작" 클릭 시점의 제목 선택을 잠가 둔다
let byIdx={}; // idx → comment let byIdx={}; // idx → comment
let sel={}; // hl.id → idx 배열(선택 순서 유지: matched 먼저) let sel={}; // hl.id → idx 배열(선택 순서 유지: matched 먼저)
let curId=null; // 현재 보고 있는 하이라이트 ID let curId=null; // 현재 보고 있는 하이라이트 ID
let dropped=new Set(); // 사용자가 X로 제외한 ID(문자열) — 빌드에서 제외. 되돌리기 가능 let dropped=new Set(); // 1차 검토에서 X로 제외한 ID(문자열) — 준비에서 제외. 되돌리기 가능
const isDropped=(id)=>dropped.has(String(id)); const isDropped=(id)=>dropped.has(String(id));
/* ── h-lab timeAgo 이식 ── */ /* ── h-lab timeAgo 이식 ── */
@ -25,7 +29,8 @@ let PASTE_HL=null; // 📋 붙여넣기 탭의 가상 하이라이트
function hlById(id){ function hlById(id){
if(id==="yt") return YT_HL; if(id==="yt") return YT_HL;
if(id==="paste") return PASTE_HL; if(id==="paste") return PASTE_HL;
return A.highlights.find(h=>h.id===id); // 자동 탭: cuts/need 는 2차(prepare) 결과에만 있다 — 카드 선택은 항상 2차에서만 일어난다.
return P&&P.highlights.find(h=>h.id===id);
} }
/* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */ /* ── 카드 DOM (h-lab renderCards 구조와 동일) ── */
@ -417,41 +422,37 @@ function showId(hlId){
applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신 applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신
} }
/* ── ID 제외 / 되돌리기 /* ── 1차 검토: ID 제외 / 되돌리기
지우지 않고 '제외' 상태로만 둔다 실수로 눌러도 번에 복구 가능(되돌리기 지원). 지우지 않고 '제외' 상태로만 둔다 실수로 눌러도 번에 복구 가능(되돌리기 지원).
제외된 ID는 빌드에서 빠지고, ID가 잡고 있던 댓글도 '사용중' 표시에서 풀린다. */ 제외된 ID는 /auto/prepare 넘어간다 받아쓰기를 아예 돌려 낭비가 없다(Task 7). */
function liveIds(){ function liveIds(){
if(!A) return []; if(!A) return [];
return A.highlights.filter(h=>!h.error&&!isDropped(h.id)).map(h=>h.id); return A.highlights.filter(h=>!h.error&&!isDropped(h.id)).map(h=>h.id);
} }
function updateBuildBtn(){ function updatePrepBtn(){
const btn=$("#autoBuild"); if(!btn||!A) return; const btn=$("#autoPrepGo"); if(!btn||!A) return;
const n=liveIds().length; const n=liveIds().length;
btn.disabled=(n===0); btn.disabled=(n===0);
btn.textContent=n?(n+"개 전부 만들기"):"만들 ID가 없습니다 — 제외를 해제하세요"; btn.textContent=n?(n+"개 준비 시작"):"준비할 ID가 없습니다 — 제외를 해제하세요";
} }
function setDropped(hlId,on){ function togglePickDrop(hlId){
const key=String(hlId); const on=!isDropped(hlId);
if(on) dropped.add(key); else dropped.delete(key); if(on) dropped.add(String(hlId)); else dropped.delete(String(hlId));
const tab=$("#idtab-"+hlId); const card=$("#pick-"+hlId);
if(tab) tab.classList.toggle("dropped",on); if(card) card.classList.toggle("dropped-pick",on);
const x=$("#xdel-"+hlId); const btn=$("#pickx-"+hlId);
if(x){ if(btn){
x.textContent=on?"↺":"✕"; btn.textContent=on?"↺ 되돌리기":"✕ 제외";
x.title=on?"다시 포함":"이 ID 제외 (생성 안 함)"; btn.title=on?"다시 포함":"이 ID 제외 (준비 안 함)";
x.setAttribute("aria-label","ID "+hlId+(on?" 다시 포함":" 제외 — 생성하지 않음"));
x.setAttribute("aria-pressed",on?"true":"false");
} }
const box=$("#hlbox-"+hlId); updatePrepBtn();
if(box&&on) box.classList.remove("show"); }
if(on&&String(curId)===key){ // 보던 탭을 제외 → 남은 첫 탭으로 이동 /* ── 2차 검토: '전부 만들기' 버튼 — 준비(prepare)를 마친 편집안 개수 기준 ── */
const nxt=liveIds()[0]; function updateBuildBtn(){
if(nxt!=null) showId(nxt); else curId=null; const btn=$("#autoBuild"); if(!btn||!P) return;
} const n=(P.highlights||[]).length;
if(!on) showId(hlId); // 되돌리면 그 탭을 연다 btn.disabled=(n===0);
refreshSel(hlId); btn.textContent=n?(n+"개 전부 만들기"):"만들 편집안이 없습니다";
if(curId!=null) applyUsedMarks(curId);
updateBuildBtn();
} }
/* (JSON ) /* (JSON )
@ -549,7 +550,7 @@ function applyMode(){
$("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)"; $("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)";
} }
/* ── 분석 ── */ /* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */
async function analyze(){ async function analyze(){
const m=curMode(); const m=curMode();
const url=$("#autoUrl").value.trim(); const url=$("#autoUrl").value.trim();
@ -559,6 +560,11 @@ async function analyze(){
if(PASTE_UI[m]&&!$("#apaste").value.trim()){ if(PASTE_UI[m]&&!$("#apaste").value.trim()){
alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;} alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;}
$("#autoGo").disabled=true;$("#autoGo").textContent="분석 중…"; $("#autoGo").disabled=true;$("#autoGo").textContent="분석 중…";
A=null;P=null;dropped=new Set();TITLE_PICKS={};AUTO_AID=null;PREP_ID=null;
$("#autoPickReview").innerHTML="";
$("#autoPrepGo").style.display="none";$("#autoPrepGo").disabled=false;
$("#autoPrepSteps").innerHTML="";$("#autoPrepSteps").style.display="none";
$("#autoPrepLog").innerHTML="";
$("#autoReview").innerHTML="";$("#autoSummary").style.display="none"; $("#autoReview").innerHTML="";$("#autoSummary").style.display="none";
$("#autoBuild").style.display="none";$("#autoLog").innerHTML=""; $("#autoBuild").style.display="none";$("#autoLog").innerHTML="";
$("#buildBoard").style.display="none";$("#buildBoard").innerHTML=""; $("#buildBoard").style.display="none";$("#buildBoard").innerHTML="";
@ -572,7 +578,8 @@ async function analyze(){
res=await(await fetch("/auto/analyze",{method:"POST",body:fd})).json(); res=await(await fetch("/auto/analyze",{method:"POST",body:fd})).json();
}catch(e){return failA("요청 실패: "+e);} }catch(e){return failA("요청 실패: "+e);}
if(res.error) return failA(res.error); if(res.error) return failA(res.error);
const es=new EventSource("/auto/stream/"+res.analysis_id); AUTO_AID=res.analysis_id;
const es=new EventSource("/auto/stream/"+AUTO_AID);
es.onmessage=(m)=>{ es.onmessage=(m)=>{
const ev=JSON.parse(m.data); const ev=JSON.parse(m.data);
if(ev.type==="manifest") renderASteps(ev.steps); if(ev.type==="manifest") renderASteps(ev.steps);
@ -604,49 +611,152 @@ function updateAStep(ev){
if(ev.detail){const d=el.querySelector(".sdetail");d.hidden=false;d.textContent=ev.detail;}} if(ev.detail){const d=el.querySelector(".sdetail");d.hidden=false;d.textContent=ev.detail;}}
} }
/* ── 검토 화면 (ID별 탭) ── */ /* 1 ( · · ). .
전환 없이 세로로 쌓는다(보통 5개뿐이라 전부 보여도 부담 없다) 지금 검토 화면에서
댓글 영역만 것이 1 검토라는 설계를 그대로 따른다. */
function onResult(ev){ function onResult(ev){
A=ev;byIdx={};sel={};curId=null;dropped=new Set(); A=ev;
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;}); const R=$("#autoPickReview");R.innerHTML="";
(ev.warnings||[]).forEach(w=>alog("⚠️ "+w)); let ok=0;
const R=$("#autoReview");R.innerHTML="";
const tabs=document.createElement("div");tabs.className="idtabs";tabs.id="idTabs";
R.appendChild(tabs);
let firstOk=null,ok=0;
for(const hl of ev.highlights){ for(const hl of ev.highlights){
// 탭 버튼 const card=document.createElement("div");
const tab=document.createElement("button"); card.className="hlbox show pickcard";card.id="pick-"+hl.id;
tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id;
if(hl.error){ if(hl.error){
tab.classList.add("err"); card.classList.add("errbox");
tab.innerHTML='<span class="tt">ID '+hl.id+' ✗</span><span class="ts">생성 실패 (클릭=사유)</span>'; card.innerHTML='<div class="pickhead"><h3 style="margin:0;">ID '+hl.id+' ✗</h3>'+
tab.title=hl.error; '<span style="color:var(--danger);font-size:12.5px;">생성 실패</span></div>'+
tab.addEventListener("click",()=>alog("ID "+hl.id+" 실패 사유: "+hl.error)); '<div class="hlmeta" style="color:var(--danger);">'+esc(hl.error)+'</div>';
tabs.appendChild(tab); R.appendChild(card);
continue; continue;
} }
ok++; if(firstOk===null) firstOk=hl.id; ok++;
tab.innerHTML='<span class="tt">ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+"</span>"+ const cuts=hl.paste.cuts;
'<span class="ts"></span>'; let html='<div class="pickhead"><h3 style="margin:0;">ID '+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+
tab.addEventListener("click",()=>{ // 제외된 탭은 클릭만으로도 되돌리기 "</h3><span style='color:var(--muted2);font-size:12.5px;'>"+esc(hl.reason||"")+"</span>"+
if(isDropped(hl.id)) setDropped(hl.id,false); else showId(hl.id); '<button type="button" class="ghost pickx" id="pickx-'+hl.id+'">✕ 제외</button></div>'+
}); '<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요</div>";
// ✕ 는 탭 버튼의 '형제' (button 중첩 불가) — 래퍼로 감싸 겹쳐 놓는다 let opts=[];
const wrap=document.createElement("div"); if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이)
wrap.className="idtabwrap"; html+='<div class="hlsec">제목: '+
const x=document.createElement("button"); '<input class="hlinput" id="hltop-'+hl.id+'" placeholder="윗줄 · 주황 (선택)"> '+
x.type="button";x.className="xdel";x.id="xdel-"+hl.id;x.textContent="✕"; '<input class="hlinput" id="hlmain-'+hl.id+'" placeholder="아랫줄 · 흰색 (선택)"></div>';
x.title="이 ID 제외 (생성 안 함)"; }else{
x.setAttribute("aria-label","ID "+hl.id+" 제외 — 생성하지 않음"); const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"};
x.setAttribute("aria-pressed","false"); opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main));
x.addEventListener("click",(e)=>{ html+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
e.stopPropagation(); opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
setDropped(hl.id,!isDropped(hl.id)); (t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
}); }
wrap.appendChild(tab);wrap.appendChild(x); card.innerHTML=html;
tabs.appendChild(wrap); card.dataset.titles=JSON.stringify(opts);
card.appendChild(cutsSection(hl)); // 영상 편집안(컷 목록·JSON) — 기본 접힘
R.appendChild(card);
$("#pickx-"+hl.id).addEventListener("click",()=>togglePickDrop(hl.id));
}
if(ok){$("#autoPrepGo").style.display="block";updatePrepBtn();}
else $("#autoPrepGo").style.display="none";
}
/* " " 2(prepare)
1 카드의 select/input 값을 더는 참조하지 않으므로 미리 읽어 저장해야 한다. */
function collectTitlePicks(){
const out={};
for(const hl of A.highlights){
if(hl.error||isDropped(hl.id)) continue;
let pick;
if(hl.editable_title){
pick={top:($("#hltop-"+hl.id)?$("#hltop-"+hl.id).value.trim():""),
main:($("#hlmain-"+hl.id)?$("#hlmain-"+hl.id).value.trim():"")};
}else{
const card=$("#pick-"+hl.id);
const opts=card?JSON.parse(card.dataset.titles||"[]"):[];
const selEl=$("#hlt-"+hl.id);
pick=opts[parseInt(selEl?selEl.value:0,10)||0]||{top:"",main:""};
}
out[hl.id]=pick;
}
return out;
}
/* ── 2단계: 준비(다운로드·받아쓰기·댓글 매칭) — 제외 안 한 ID만, 서버가 순차로 돈다 ── */
async function prepareAll(){
if(!A) return;
const ids=liveIds();
if(!ids.length){alert("준비할 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;}
TITLE_PICKS=collectTitlePicks();
const btn=$("#autoPrepGo");
btn.disabled=true;btn.textContent="준비 중…";
$("#autoPrepSteps").innerHTML="";$("#autoPrepLog").innerHTML="";
$("#autoReview").innerHTML="";$("#autoSummary").style.display="none";
$("#autoBuild").style.display="none";
$("#buildBoard").style.display="none";$("#buildBoard").innerHTML="";
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="none";
let res;
try{
const fd=new FormData();
fd.append("aid",AUTO_AID);
fd.append("ids",JSON.stringify(ids));
res=await(await fetch("/auto/prepare",{method:"POST",body:fd})).json();
}catch(e){return prepFail("요청 실패: "+e);}
if(res.error) return prepFail(res.error);
PREP_ID=res.prepare_id;
const es=new EventSource("/auto/prepare/"+PREP_ID);
es.onmessage=(ev0)=>{
const ev=JSON.parse(ev0.data);
if(ev.type==="manifest") renderPrepSteps(ev.steps);
else if(ev.type==="step") updatePrepStep(ev);
else if(ev.type==="log") prepLog(ev.msg);
else if(ev.type==="result"){es.close();onPrepareResult(ev);prepDone();}
else if(ev.type==="error"){es.close();prepFail(ev.message);}
};
es.onerror=()=>{es.close();prepFail("연결이 끊겼습니다.");};
}
function prepDone(){$("#autoPrepGo").disabled=false;updatePrepBtn();}
function prepFail(m){prepLog("⚠️ "+m);prepDone();}
function prepLog(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;$("#autoPrepLog").appendChild(d);
}
function renderPrepSteps(steps){
$("#autoPrepSteps").innerHTML=steps.map(s=>
'<div class="step" id="apst-'+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("");
$("#autoPrepSteps").style.display="block";
}
function updatePrepStep(ev){
const el=$("#apst-"+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;}}
}
/* 2 ID + renderCutPanel( ).
준비(prepare) 마친 ID만 온다 실패한 ID는 경고 로그로만 알린다( 자체가 없음). */
function onPrepareResult(ev){
P=ev;byIdx={};sel={};selCut={};curId=null;
(ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;});
(ev.warnings||[]).forEach(w=>prepLog("⚠️ "+w));
const R=$("#autoReview");R.innerHTML="";
if(!ev.highlights||!ev.highlights.length){
R.innerHTML='<div class="hlbox show"><div class="hlmeta">준비된 편집안이 없습니다. 위 로그를 확인하세요.</div></div>';
$("#autoBuild").style.display="none";
return;
}
const tabs=document.createElement("div");tabs.className="idtabs";tabs.id="idTabs";
R.appendChild(tabs);
let firstId=null;
for(const hl of ev.highlights){
const src=A.highlights.find(h=>h.id===hl.id)||{};
if(firstId===null) firstId=hl.id;
const tab=document.createElement("button");
tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id;
tab.innerHTML='<span class="tt">ID '+hl.id+(src.start!=null?(" · "+fmtT(src.start)+"~"+fmtT(src.end)):"")+
'</span><span class="ts"></span>';
tab.addEventListener("click",()=>showId(hl.id));
tabs.appendChild(tab);
// 패널
const box=document.createElement("div"); const box=document.createElement("div");
box.className="hlbox";box.id="hlbox-"+hl.id; box.className="hlbox";box.id="hlbox-"+hl.id;
// 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백. // 자동 선택: 컷별 추천을 컷 순서대로. hl.cuts 가 없으면 기존 방식으로 폴백.
@ -660,48 +770,25 @@ function onResult(ev){
}else{ }else{
sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined); sel[hl.id]=(hl.matched||[]).slice(0,hl.need).filter(i=>byIdx[i]!==undefined);
} }
const cuts=hl.paste.cuts; box.innerHTML='<div class="hlmeta">카드 '+hl.need+"장 필요 · "+
let html="<h3>ID "+hl.id+" · "+fmtT(hl.start)+"~"+fmtT(hl.end)+ '<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>'+
" <span style='color:var(--muted2);font-weight:400;'>"+esc(hl.reason||"")+"</span></h3>"+ '<div class="selbar" id="hlsel-'+hl.id+'"></div>';
'<div class="hlmeta">컷 '+cuts.length+"개 · 총 "+hl.total+"초 · 카드 "+hl.need+"장 필요 · "+
'<span id="hlcnt-'+hl.id+'" class="tnum"></span></div>';
let opts=[];
if(hl.editable_title){ // 구간 통짜: 제목 직접 입력(비우면 제목 없이)
html+='<div class="hlsec">제목: '+
'<input class="hlinput" id="hltop-'+hl.id+'" placeholder="윗줄 · 주황 (선택)"> '+
'<input class="hlinput" id="hlmain-'+hl.id+'" placeholder="아랫줄 · 흰색 (선택)"></div>';
}else{
const t0={top:hl.paste.title_top,main:hl.paste.title_main,kind:"최종 선택"};
opts=[t0].concat((hl.titles||[]).filter(t=>t.top!==t0.top||t.main!==t0.main));
html+='<div class="hlsec">제목: <select id="hlt-'+hl.id+'">'+
opts.map((t,i)=>'<option value="'+i+'">'+esc(t.top)+" / "+esc(t.main)+
(t.kind?" — "+esc(t.kind):"")+"</option>").join("")+"</select></div>";
}
html+='<div class="selbar" id="hlsel-'+hl.id+'"></div>';
box.innerHTML=html;
box.dataset.titles=JSON.stringify(opts);
// 영상 편집안(컷 목록·JSON) — 선택 요약 바 위에, 기본 접힘
box.insertBefore(cutsSection(hl),$("#hlsel-"+hl.id));
renderCutPanel(box,hl.id, renderCutPanel(box,hl.id,
{cuts:hl.cuts,matched:hl.matched,candidates:hl.candidates, {cuts:hl.cuts,matched:hl.matched,candidates:ev.candidates,cutRanges:hl.cutRanges},
cutRanges:hl.paste.cuts},{unit:"컷"}); {unit:"컷"});
R.appendChild(box); R.appendChild(box);
refreshSel(hl.id); refreshSel(hl.id);
} }
// 접기/펼치기 버튼 — 탭 바 오른쪽 끝 // 접기/펼치기 버튼 — 탭 바 오른쪽 끝
if(ok){
const fb=document.createElement("button"); const fb=document.createElement("button");
fb.type="button";fb.className="idtab ccfoldbtn";fb.id="ccFoldBtn"; fb.type="button";fb.className="idtab ccfoldbtn";fb.id="ccFoldBtn";
fb.innerHTML='<span class="tt">▴ 댓글 접기</span><span class="ts">카드 표시 상태</span>'; fb.innerHTML='<span class="tt">▴ 댓글 접기</span><span class="ts">카드 표시 상태</span>';
fb.addEventListener("click",()=>setFolded(!$("#autoReview").classList.contains("folded"))); fb.addEventListener("click",()=>setFolded(!$("#autoReview").classList.contains("folded")));
tabs.appendChild(fb); tabs.appendChild(fb);
} if(firstId!==null) showId(firstId);
if(firstOk!==null) showId(firstOk);
if(ok){
$("#autoBuild").style.display="block"; $("#autoBuild").style.display="block";
updateBuildBtn(); updateBuildBtn();
if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex"; if($("#autoFixedWrap")) $("#autoFixedWrap").style.display="flex";
}
} }
/* ── 캡처 + 순차 빌드 ── */ /* ── 캡처 + 순차 빌드 ── */
@ -744,8 +831,8 @@ function boardSet(hlId,stat,msg,cls){
async function buildAll(){ async function buildAll(){
const btn=$("#autoBuild"); const btn=$("#autoBuild");
const hls=A.highlights.filter(h=>!h.error&&!isDropped(h.id)); // 제외한 ID는 안 만듦 const hls=(P&&P.highlights)||[]; // 준비(prepare)를 마친 ID만 — 실패·제외된 ID는 여기 없음
if(!hls.length){alert("만들 ID가 없습니다. 제외(✕)를 하나 이상 해제하세요.");return;} if(!hls.length){alert("만들 편집안이 없습니다.");return;}
btn.disabled=true; btn.disabled=true;
setFolded(true); // 빌드 시작 → 댓글 영역 접기(진행판에 집중) setFolded(true); // 빌드 시작 → 댓글 영역 접기(진행판에 집중)
boardInit(hls); boardInit(hls);
@ -763,23 +850,18 @@ async function buildAll(){
await nextFrame(); await nextFrame();
boardSet(hl.id,"🔄 진행","", "active"); boardSet(hl.id,"🔄 진행","", "active");
try{ try{
let pick; // 제목은 1차 검토("준비 시작" 클릭 시점)에 이미 잠가 뒀다 — 다운로드·받아쓰기가
if(hl.editable_title){ // 끝난 지금은 1차 카드의 select/input 이 그대로 있어도 참조하지 않는다.
pick={top:($("#hltop-"+hl.id)?$("#hltop-"+hl.id).value.trim():""), const pick=TITLE_PICKS[hl.id]||{top:"",main:""};
main:($("#hlmain-"+hl.id)?$("#hlmain-"+hl.id).value.trim():"")};
}else{
const opts=JSON.parse($("#hlbox-"+hl.id).dataset.titles);
pick=opts[parseInt($("#hlt-"+hl.id).value,10)||0];
}
const paste={...hl.paste,title_top:pick.top,title_main:pick.main};
const fd=new FormData(); const fd=new FormData();
fd.append("data",JSON.stringify(paste)); fd.append("aid",AUTO_AID);
fd.append("tag","하이라이트"+hl.id); fd.append("id",String(hl.id));
fd.append("title_top",pick.top||"");
fd.append("title_main",pick.main||"");
fd.append("video_scale",$("#vscale").value||"144"); fd.append("video_scale",$("#vscale").value||"144");
fd.append("flip",$("#flip").checked?"1":"0"); fd.append("flip",$("#flip").checked?"1":"0");
fd.append("scene",$("#scene").checked?"1":"0"); fd.append("scene",$("#scene").checked?"1":"0");
fd.append("bg_white",$("#bgwhite").checked?"1":"0"); fd.append("bg_white",$("#bgwhite").checked?"1":"0");
fd.append("remove_silence",$("#rmsilence").checked?"1":"0");
fd.append("asr_bottom",$("#asrbottom").checked?"1":"0"); fd.append("asr_bottom",$("#asrbottom").checked?"1":"0");
fd.append("cards_fixed",($("#autoCardsFixed")&&$("#autoCardsFixed").checked)?"1":"0"); fd.append("cards_fixed",($("#autoCardsFixed")&&$("#autoCardsFixed").checked)?"1":"0");
const cmap=selCut[hl.id]||{}; const cmap=selCut[hl.id]||{};
@ -1190,6 +1272,7 @@ document.addEventListener("DOMContentLoaded",()=>{
document.querySelectorAll('input[name="amode"]').forEach(r=> document.querySelectorAll('input[name="amode"]').forEach(r=>
r.addEventListener("change",applyMode)); r.addEventListener("change",applyMode));
applyMode(); applyMode();
$("#autoPrepGo").addEventListener("click",prepareAll);
$("#autoBuild").addEventListener("click",buildAll); $("#autoBuild").addEventListener("click",buildAll);
$("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();}); $("#autoSettings").addEventListener("toggle",()=>{if($("#autoSettings").open)loadPrompts();});
$("#pSave").addEventListener("click",()=>savePrompts(false)); $("#pSave").addEventListener("click",()=>savePrompts(false));

View File

@ -162,8 +162,8 @@
.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,#ytReview,#ytSummary,#pasteReview,#pasteSummary{--rw:min(1240px,calc(100vw - 40px)); #autoPickReview,#autoReview,#autoSummary,#ytReview,#ytSummary,#pasteReview,#pasteSummary{
width:var(--rw);margin-left:calc((100% - var(--rw))/2);} --rw:min(1240px,calc(100vw - 40px));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;
padding:10px 0;background:var(--bg);margin-top:10px;} padding:10px 0;background:var(--bg);margin-top:10px;}
@ -199,6 +199,15 @@
.hlbox select{background:var(--surf2);border:1px solid var(--border);border-radius:7px; .hlbox select{background:var(--surf2);border:1px solid var(--border);border-radius:7px;
color:var(--text);font-size:12.5px;padding:8px 10px;max-width:100%;} color:var(--text);font-size:12.5px;padding:8px 10px;max-width:100%;}
.hlbox .hlprog{margin-top:8px;font-family:var(--mono);font-size:12px;color:var(--muted2);white-space:pre-line;} .hlbox .hlprog{margin-top:8px;font-family:var(--mono);font-size:12px;color:var(--muted2);white-space:pre-line;}
/* ── 1차 검토(하이라이트 카드) — 댓글 없이 제목만 고르는 화면. .hlbox 를 그대로 쓰되
탭 전환 없이 세로로 쌓아 보여준다(하이라이트가 5개뿐이라 전부 보여도 부담 없음). ── */
.pickcard{margin-top:10px;display:block;}
.pickcard:first-child{margin-top:4px;}
.pickhead{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:6px;}
.pickx{width:auto;margin:0 0 0 auto;padding:6px 12px;flex:none;}
.pickcard.dropped-pick{opacity:.5;border-style:dashed;}
.pickcard.dropped-pick .pickx{color:var(--accent);}
.pickcard.errbox{border-color:var(--danger);}
/* ── 영상 편집안(JSON 컷) — 기본 접힘, 클릭해 펼침 ── */ /* ── 영상 편집안(JSON 컷) — 기본 접힘, 클릭해 펼침 ── */
.cutsbox{margin-top:12px;border:1px solid var(--border);border-radius:9px; .cutsbox{margin-top:12px;border:1px solid var(--border);border-radius:9px;
background:var(--surf2);overflow:hidden;} background:var(--surf2);overflow:hidden;}
@ -478,6 +487,12 @@
<div id="autoSteps"></div> <div id="autoSteps"></div>
<div id="autoLog"></div> <div id="autoLog"></div>
<!-- 1차 검토 — 하이라이트 카드(제목 선택 · ✕ 제외 · 편집안 접기). 댓글 영역 없음. -->
<div id="autoPickReview"></div>
<button class="run" id="autoPrepGo" style="display:none;margin-top:14px;"></button>
<div id="autoPrepSteps"></div>
<div id="autoPrepLog"></div>
<!-- 2차 검토 — /auto/prepare 가 준비한 ID만 컷별 댓글 선택(renderCutPanel, 기존과 동일). -->
<div id="autoReview"></div> <div id="autoReview"></div>
<button class="run" id="autoBuild" style="display:none;margin-top:14px;"></button> <button class="run" id="autoBuild" style="display:none;margin-top:14px;"></button>
<label id="autoFixedWrap" style="display:none;align-items:center;gap:7px;margin-top:10px;color:var(--muted2);font-size:12.5px;cursor:pointer;"> <label id="autoFixedWrap" style="display:none;align-items:center;gap:7px;margin-top:10px;color:var(--muted2);font-size:12.5px;cursor:pointer;">