자동 탭: 분석→준비→검토 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:
parent
3c013dcc88
commit
e650cfb992
269
server/app.py
269
server/app.py
@ -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")
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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;">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user