diff --git a/server/app.py b/server/app.py index 7ba0dd5..f75020b 100644 --- a/server/app.py +++ b/server/app.py @@ -57,6 +57,12 @@ PSTATES: dict[str, dict] = {} # /yt/stream 이 채우고 /yt/build 가 꺼내 쓴다(서버 재시작 시 소실 — 재분석 필요). 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), "댓글카드") @@ -472,7 +478,13 @@ async def auto_analyze(url: str = Form(""), mode: str = Form("full"), @app.get("/auto/stream/{aid}") 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) async def gen(): @@ -481,7 +493,6 @@ async def auto_stream(aid: str) -> StreamingResponse: return url = a["url"] mode = a.get("mode", "full") - warnings: list[str] = [] highlights: list[dict] = [] def _need(total: float) -> int: @@ -511,8 +522,6 @@ async def auto_stream(aid: str) -> StreamingResponse: # ── 오팔 JSON 여러 개 — Gemini 안 씀 ── yield _sse({"type": "manifest", "steps": [ {"id": "parse", "label": "오팔 JSON 파싱"}, - {"id": "comments", "label": "댓글 수집 (h-lab)"}, - {"id": "recommend", "label": "컷별 댓글 추천"}, ]}) yield _sse({"type": "step", "id": "parse", "status": "start"}) import re as _re @@ -579,14 +588,10 @@ async def auto_stream(aid: str) -> StreamingResponse: url = best_url yield _sse({"type": "step", "id": "parse", "status": "done", "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": # ── 구간 JSON 붙여넣기 — Gemini 안 씀. 구간 5개를 그대로 통짜로 ── yield _sse({"type": "manifest", "steps": [ {"id": "parse", "label": "구간 JSON 파싱"}, - {"id": "comments", "label": "댓글 수집 (h-lab)"}, - {"id": "recommend", "label": "컷별 댓글 추천"}, ]}) yield _sse({"type": "step", "id": "parse", "status": "start"}) try: @@ -597,24 +602,16 @@ async def auto_stream(aid: str) -> StreamingResponse: highlights.extend(_whole_hl(c) for c in cands) yield _sse({"type": "step", "id": "parse", "status": "done", "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: steps = [{"id": "step1", "label": "하이라이트 구간 선정 (Gemini)"}] if mode == "full": steps.append({"id": "step3", "label": "편집안 생성 (Gemini, 구간별 동시)"}) - steps.append({"id": "comments", "label": "댓글 수집 (h-lab)"}) - steps.append({"id": "recommend", "label": "컷별 댓글 추천"}) 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 ── yield _sse({"type": "step", "id": "step1", "status": "start"}) try: cands = await asyncio.to_thread(autoplan.select_highlights, url) except Exception as exc: # noqa: BLE001 - com_task.cancel() yield _sse({"type": "error", "message": f"Step 1 실패 — {type(exc).__name__}: {exc}\n" "오팔 → 📋 오팔 JSON 방식으로도 만들 수 있습니다."}) @@ -673,58 +670,143 @@ async def auto_stream(aid: str) -> StreamingResponse: yield _sse({"type": "step", "id": "step3", "status": "done", "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] = [] try: - comments = await com_task + comments = await asyncio.to_thread(hlab.fetch_comments, url) yield _sse({"type": "step", "id": "comments", "status": "done", "detail": f"{len(comments)}개"}) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # noqa: BLE001 — 댓글 실패는 진행을 막지 않는다 warnings.append(f"h-lab 연결 실패 — 댓글 없이 진행합니다 ({exc})") yield _sse({"type": "step", "id": "comments", "status": "done", "detail": "실패(생략)"}) - # 댓글 매칭 — 전체 전송, 브라우저가 '더보기'로 30장씩 나눠 그린다. - # 후보(candidates)는 분:초 언급이 아예 없는 댓글만 — 타임스탬프 댓글은 - # 자기 구간의 ⭐에서 잡히므로, 다른 구간 얘기하는 댓글이 섞이지 않게. - # - # 하이라이트마다 Gemini 를 순차로 부른다(동시 호출은 429 를 부른다 — Step 3 도 같은 이유로 - # 시차 재시도를 쓴다). 최악 5×90초라 진행 표시가 없으면 사용자가 멈춘 줄 알고 새로고침해 - # 분석이 통째로 날아간다 → 스텝 + 하이라이트별 로그를 반드시 흘린다. - no_ts = [c for c in comments if not c["times"]] - targets = [h for h in highlights if "paste" in h] - yield _sse({"type": "step", "id": "recommend", "status": "start"}) - t_rec = time.perf_counter() - for n, h in enumerate(targets, 1): - matched = hlab.match_window(comments, h["start"], h["end"]) - h["matched"] = matched - h["candidates"] = hlab.top_liked(no_ts, set(matched), len(no_ts)) - yield _sse({"type": "log", - "msg": f"ID {h.get('id')} 컷별 댓글 추천 중… " - f"({n}/{len(targets)}, 컷 " - f"{len(h['paste'].get('cuts') or [])}개)"}) - # 컷별 추천 — 실패해도 위의 matched/candidates 로 화면이 돌아간다. - # Gemini 호출이 섞여 있어 블로킹이므로 스레드로 뺀다. + yield _sse({"type": "step", "id": "prepare", "status": "start"}) + highlights_out: list[dict] = [] + all_ranges: list[tuple] = [] + # ⚠ 순차로 돌려라 — yt-dlp·ffmpeg·Whisper 가 CPU 를 다 쓴다(전역 방침). + for h in targets: + hid = h["id"] + state = None + failed = False + try: + async for ev in paste_analyze(h["paste"], f"auto_{aid}_{hid}", + remove_silence=True, asr_bottom=True): + if ev.get("type") == "state": # 내부 전용 — 밖으로 흘리지 않는다 + state = ev["state"] + continue + if ev.get("type") == "log": + yield _sse({"type": "log", "msg": f"ID {hid}: {ev.get('msg')}"}) + continue + yield _sse(ev) # step(download/asr) — manifest 밖 id 는 화면에서 무시됨 + except Exception as exc: # noqa: BLE001 — 이 ID만 건너뛰고 나머지는 계속 + failed = True + 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: cuts, need, ai_failed = await asyncio.to_thread( - recommend.build_highlight_cuts, h, comments) - if cuts: - h["cuts"], h["need"] = cuts, need + recommend.cuts_from_state, places, orig, state["bottom_caps"], comments) if ai_failed: - # cuts 는 분:초·단어겹침·좋아요만으로 채워져 비지 않는다 — 이 신호가 없으면 - # 429·503(재시도까지 소진)이 조용히 삼켜져 아무도 모른다. "무엇이 안 됐고 - # 그래서 어떻게 됐는지"를 한 문장에 담는다(사용자가 로그 영역만 보고 지나침). - warnings.append( - f"ID {h.get('id')} AI 추천 실패(Gemini 응답 없음) — " - "분:초·자막단어·좋아요로 배정했습니다") - except Exception as exc: # noqa: BLE001 — 추천 실패가 생성을 막으면 안 된다 - warnings.append( - f"ID {h.get('id')} 컷별 추천 실패 — 기존 방식으로 표시 " - f"({type(exc).__name__}: {exc})") - yield _sse({"type": "step", "id": "recommend", "status": "done", - "elapsed": round(time.perf_counter() - t_rec, 1), - "detail": f"{len(targets)}개 하이라이트"}) - yield _sse({"type": "result", "highlights": highlights, - "comments": comments, "warnings": warnings}) + warnings.append(f"ID {hid} AI 추천 실패(Gemini 응답 없음) — " + "분:초·자막단어·좋아요로 배정했습니다") + except Exception as exc: # noqa: BLE001 + cuts, need = None, max(1, int(state["timeline_dur"] // 3)) + warnings.append(f"ID {hid} 컷별 추천 실패 — 컷 정보 없이 진행합니다 " + f"({type(exc).__name__}: {exc})") + + PSTATES[f"{aid}:{hid}"] = {"state": state, "places": places, "orig": orig, + "payload": h["paste"]} + matched = hlab.match_ranges(comments, orig) if comments else [] + 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", 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") async def auto_build( - data: str = Form(...), - tag: str = Form(""), + aid: str = Form(...), + hid: str = Form(..., alias="id"), + title_top: str = Form(""), + title_main: str = Form(""), cards: list[UploadFile] = File(default=[]), + card_cuts: str = Form(""), video_scale: str = Form("144"), flip: str = Form(""), scene: str = Form(""), bg_white: str = Form(""), - remove_silence: str = Form(""), - asr_bottom: str = Form(""), cards_fixed: str = Form(""), - card_cuts: str = Form(""), + asr_bottom: str = Form("1"), + comments_dir: str = Form(""), ) -> JSONResponse: - """자동 탭 빌드 — 붙여넣기 스키마 JSON + 카드 PNG들 → 기존 paste job. + """자동 탭 3단계 — /auto/prepare 가 PSTATES 에 보관한 상태로 드래프트만 만든다. - tag(예: '하이라이트1')는 드래프트 이름 꼬리표 — 같은 영상 5개가 서로 - 덮어쓰는 것을 막는다. 진행은 기존 /stream/{job_id} 로 본다. + 다운로드·받아쓰기는 다시 하지 않는다(/paste/build 와 같은 방식) — aid+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: - payload = parse_paste(data) - except ValueError as e: - return JSONResponse({"error": str(e)}, 400) - safe_tag = "".join(ch for ch in tag if ch.isalnum() or ch in "-_")[:20] - sig = (payload["url"] + "|" + safe_tag + "|" - + "|".join(f"{s:.3f}-{e:.3f}" for s, e, _, _ in payload["cuts"])) + 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 = [] + + sig = (key + "|" + card_cuts + "|" + video_scale + "|" + flip + "|" + scene + "|" + + bg_white + "|" + cards_fixed + "|" + asr_bottom + "|" + str(len(cards))) h = hashlib.sha1(sig.encode()).hexdigest()[:12] - cdir = "" + cdir = comments_dir.strip() if cards: cdir = os.path.join(COMMENTS_DIR, h) if os.path.isdir(cdir): # 재빌드 시 이전 카드 잔재 제거 @@ -816,25 +923,13 @@ async def auto_build( body = await f.read() with open(os.path.join(cdir, f"{i:03d}.png"), "wb") as out: 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] = { - "paste": payload, "draft_name": f"auto_{h}", - "video_scale": _scale(video_scale), "flip": _truthy(flip), - "scene": _truthy(scene), "comments_dir": cdir, - "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, + "paste_state": state, "card_cuts": cut_map, + "video_scale": _scale(video_scale), "flip": _truthy(flip), "scene": _truthy(scene), + "comments_dir": cdir, "bg_white": _truthy(bg_white), "cards_fixed": _truthy(cards_fixed), } - return JSONResponse({"job_id": h, "cuts": len(payload["cuts"]), - "cards": len(cards)}) + return JSONResponse({"job_id": h}) @app.post("/paste/analyze") diff --git a/server/static/auto.js b/server/static/auto.js index 2290e00..6e5db5b 100644 --- a/server/static/auto.js +++ b/server/static/auto.js @@ -2,11 +2,15 @@ 카드 DOM·캡처 방식은 h-lab comment-cards 와 동일 계열(modern-screenshot, 4배). */ (function(){ 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 sel={}; // hl.id → idx 배열(선택 순서 유지: matched 먼저) let curId=null; // 현재 보고 있는 하이라이트 ID -let dropped=new Set(); // 사용자가 X로 제외한 ID(문자열) — 빌드에서 제외. 되돌리기 가능 +let dropped=new Set(); // 1차 검토에서 X로 제외한 ID(문자열) — 준비에서 제외. 되돌리기 가능 const isDropped=(id)=>dropped.has(String(id)); /* ── h-lab timeAgo 이식 ── */ @@ -25,7 +29,8 @@ let PASTE_HL=null; // 📋 붙여넣기 탭의 가상 하이라이트 function hlById(id){ if(id==="yt") return YT_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 구조와 동일) ── */ @@ -417,41 +422,37 @@ function showId(hlId){ applyUsedMarks(hlId); // 탭 열 때 최신 선택 상태로 갱신 } -/* ── ID 제외 / 되돌리기 ── +/* ── 1차 검토: ID 제외 / 되돌리기 ── 지우지 않고 '제외' 상태로만 둔다 → 실수로 눌러도 한 번에 복구 가능(되돌리기 지원). - 제외된 ID는 빌드에서 빠지고, 그 ID가 잡고 있던 댓글도 '사용중' 표시에서 풀린다. */ + 제외된 ID는 /auto/prepare 에 안 넘어간다 — 받아쓰기를 아예 안 돌려 낭비가 없다(Task 7). */ function liveIds(){ if(!A) return []; return A.highlights.filter(h=>!h.error&&!isDropped(h.id)).map(h=>h.id); } -function updateBuildBtn(){ - const btn=$("#autoBuild"); if(!btn||!A) return; +function updatePrepBtn(){ + const btn=$("#autoPrepGo"); if(!btn||!A) return; const n=liveIds().length; btn.disabled=(n===0); - btn.textContent=n?(n+"개 전부 만들기"):"만들 ID가 없습니다 — 제외를 해제하세요"; + btn.textContent=n?(n+"개 준비 시작"):"준비할 ID가 없습니다 — 제외를 해제하세요"; } -function setDropped(hlId,on){ - const key=String(hlId); - if(on) dropped.add(key); else dropped.delete(key); - const tab=$("#idtab-"+hlId); - if(tab) tab.classList.toggle("dropped",on); - const x=$("#xdel-"+hlId); - if(x){ - x.textContent=on?"↺":"✕"; - x.title=on?"다시 포함":"이 ID 제외 (생성 안 함)"; - x.setAttribute("aria-label","ID "+hlId+(on?" 다시 포함":" 제외 — 생성하지 않음")); - x.setAttribute("aria-pressed",on?"true":"false"); +function togglePickDrop(hlId){ + const on=!isDropped(hlId); + if(on) dropped.add(String(hlId)); else dropped.delete(String(hlId)); + const card=$("#pick-"+hlId); + if(card) card.classList.toggle("dropped-pick",on); + const btn=$("#pickx-"+hlId); + if(btn){ + btn.textContent=on?"↺ 되돌리기":"✕ 제외"; + btn.title=on?"다시 포함":"이 ID 제외 (준비 안 함)"; } - const box=$("#hlbox-"+hlId); - if(box&&on) box.classList.remove("show"); - if(on&&String(curId)===key){ // 보던 탭을 제외 → 남은 첫 탭으로 이동 - const nxt=liveIds()[0]; - if(nxt!=null) showId(nxt); else curId=null; - } - if(!on) showId(hlId); // 되돌리면 그 탭을 연다 - refreshSel(hlId); - if(curId!=null) applyUsedMarks(curId); - updateBuildBtn(); + updatePrepBtn(); +} +/* ── 2차 검토: '전부 만들기' 버튼 — 준비(prepare)를 마친 편집안 개수 기준 ── */ +function updateBuildBtn(){ + const btn=$("#autoBuild"); if(!btn||!P) return; + const n=(P.highlights||[]).length; + btn.disabled=(n===0); + btn.textContent=n?(n+"개 전부 만들기"):"만들 편집안이 없습니다"; } /* ── 영상 편집안(JSON 컷) — 기본 접힘 ── @@ -549,7 +550,7 @@ function applyMode(){ $("#autoGo").textContent=p?"댓글 매칭 시작":"분석 시작 (하이라이트 5개)"; } -/* ── 분석 ── */ +/* ── 분석 (1차: 편집안·타이틀 후보만 — 댓글 매칭 없음) ── */ async function analyze(){ const m=curMode(); const url=$("#autoUrl").value.trim(); @@ -559,6 +560,11 @@ async function analyze(){ if(PASTE_UI[m]&&!$("#apaste").value.trim()){ alert(m==="wpaste"?"구간 JSON을 붙여넣으세요.":"오팔 JSON을 붙여넣으세요.");return;} $("#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"; $("#autoBuild").style.display="none";$("#autoLog").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(); }catch(e){return failA("요청 실패: "+e);} 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)=>{ const ev=JSON.parse(m.data); 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;}} } -/* ── 검토 화면 (ID별 탭) ── */ +/* ── 1차 검토 화면 — 하이라이트 카드(제목 선택 · ✕ 제외 · 편집안 접기). 댓글 영역 없음. + 탭 전환 없이 세로로 쌓는다(보통 5개뿐이라 전부 보여도 부담 없다) — 지금 검토 화면에서 + 댓글 영역만 뺀 것이 1차 검토라는 설계를 그대로 따른다. */ function onResult(ev){ - A=ev;byIdx={};sel={};curId=null;dropped=new Set(); - (ev.comments||[]).forEach(c=>{byIdx[c.idx]=c;}); - (ev.warnings||[]).forEach(w=>alog("⚠️ "+w)); - const R=$("#autoReview");R.innerHTML=""; - const tabs=document.createElement("div");tabs.className="idtabs";tabs.id="idTabs"; - R.appendChild(tabs); - let firstOk=null,ok=0; + A=ev; + const R=$("#autoPickReview");R.innerHTML=""; + let ok=0; for(const hl of ev.highlights){ - // 탭 버튼 - const tab=document.createElement("button"); - tab.type="button";tab.className="idtab";tab.id="idtab-"+hl.id; + const card=document.createElement("div"); + card.className="hlbox show pickcard";card.id="pick-"+hl.id; if(hl.error){ - tab.classList.add("err"); - tab.innerHTML='ID '+hl.id+' ✗생성 실패 (클릭=사유)'; - tab.title=hl.error; - tab.addEventListener("click",()=>alog("ID "+hl.id+" 실패 사유: "+hl.error)); - tabs.appendChild(tab); + card.classList.add("errbox"); + card.innerHTML='