diff --git a/backend/generator/naver.py b/backend/generator/naver.py index 9bd0204..8f7fdf4 100644 --- a/backend/generator/naver.py +++ b/backend/generator/naver.py @@ -55,6 +55,39 @@ def looks_like_naver_place(s: str) -> bool: return bool(s) and ("naver.me/" in s or "place.naver.com" in s or "map.naver.com" in s) +def _clean_store(raw: str) -> str: + """og:title/title 값에서 실제 가게명만 정리. 예: '골목냉면 : 네이버\\x1c' → '골목냉면'.""" + if not raw: + return "" + s = re.sub(r"[\x00-\x1f]", "", raw) # 제어문자 제거 + s = re.split(r"\s*[:\-|]\s*네이버", s)[0] # ' : 네이버', ' - 네이버 지도' 접미사 제거 + s = re.sub(r"\s+", " ", s).strip() + return s.splitlines()[0].strip() if s else "" + + +async def _store_name(page) -> str: + """가게명 추출. og:title 메타(가장 안정적) 우선 → title → 화면 요소. + 섹션 헤딩('AI 브리핑' 등)이나 '네이버' 포함 값은 배제한다. + (네이버가 span.GHAhO 같은 난독 클래스를 자주 바꿔 화면 셀렉터만으론 불안정하므로 메타 우선.)""" + async def _meta(): + return await page.locator("meta[property='og:title']").first.get_attribute("content", timeout=1500) + for getter in (_meta, page.title): + try: + name = _clean_store(await getter()) + if name and name != "AI 브리핑" and "네이버" not in name: + return name + except Exception: + pass + for sel in ("span.GHAhO", "#_title span", "h2"): # 최후 폴백(섹션명 배제) + try: + t = (await page.locator(sel).first.inner_text(timeout=1200)).strip().splitlines()[0] + if t and t != "AI 브리핑" and not t.startswith("AI "): + return t + except Exception: + pass + return "" + + async def _new_page(p, headful=False, viewport=None, device_scale_factor=None): """공통 브라우저 컨텍스트(스텔스 + 모바일 UA) → (browser, ctx, page).""" browser = await p.chromium.launch( @@ -136,15 +169,7 @@ async def _fetch(url, headful=False): await page.wait_for_timeout(600) await page.wait_for_timeout(800) - store = "" - for sel in ["span.GHAhO", "h2", "title"]: - try: - t = await page.locator(sel).first.inner_text(timeout=1500) - if t and t.strip(): - store = t.strip().splitlines()[0] - break - except Exception: - pass + store = await _store_name(page) res = await page.evaluate(_EXTRACT_JS) await browser.close() @@ -213,11 +238,7 @@ async def crawl_and_download(url, dst_dir, max_candidates=14, headful=False): if not pid: await browser.close() raise RuntimeError("place id 를 못 찾음.") - try: - store = (await page.locator("span.GHAhO, h2").first.inner_text(timeout=1500)).strip() - store = store.splitlines()[0] - except Exception: - store = "" + store = await _store_name(page) await page.goto(f"https://m.place.naver.com/{cat}/{pid}/photo", wait_until="domcontentloaded", timeout=30000) diff --git a/frontend/src/components.tsx b/frontend/src/components.tsx index 1b8137d..3a12609 100644 --- a/frontend/src/components.tsx +++ b/frontend/src/components.tsx @@ -402,8 +402,7 @@ export function CreateSheet({ open, onClose, onCreated }: { const [phase, setPhase] = useState('pick') const [scn, setScn] = useState(null) const [input, setInput] = useState('') - const [status, setStatus] = useState('') - const [log, setLog] = useState([]) + const [pct, setPct] = useState(0) const [err, setErr] = useState('') const [results, setResults] = useState([]) const [selected, setSelected] = useState(null) @@ -414,7 +413,7 @@ export function CreateSheet({ open, onClose, onCreated }: { useEffect(() => { if (open) { setPhase('pick'); setScn(null); setInput('') - setStatus(''); setLog([]); setErr('') + setPct(0); setErr('') setResults([]); setSelected(null); setSearching(false); setSearched(false) } return () => { if (timer.current) window.clearTimeout(timer.current) } @@ -440,14 +439,12 @@ export function CreateSheet({ open, onClose, onCreated }: { async function poll(id: string) { try { const j = await getJob(id) - setLog(j.log || []) - if (j.status === 'done') { setStatus('완성!'); await onCreated(); onClose(); return } + if (j.status === 'done') { setPct(100); await onCreated(); onClose(); return } if (j.status === 'error') { setErr(j.error || '생성 중 오류가 발생했어요.'); return } - const names = ['대본 생성', '스토리보드', '이미지·음성', '영상 합성'] - setStatus(j.status === 'queued' ? '대기 중…' : `${names[Math.max(0, (j.step || 1) - 1)]}… (${j.step || 1}/4)`) - } catch { - setStatus('상태 확인 중…') - } + // step(0~4) → 퍼센트. 완료 전엔 96%로 상한(완성 시 100). 뒤로는 안 감. + const step = j.status === 'queued' ? 0 : (j.step || 1) + setPct((prev) => Math.max(prev, Math.min(96, step * 25))) + } catch { /* 일시적 네트워크 — 다음 폴에서 복구 */ } timer.current = window.setTimeout(() => poll(id), 3000) } @@ -455,7 +452,7 @@ export function CreateSheet({ open, onClose, onCreated }: { if (!scn) return // 선택된 가게가 있으면 그 place_url 로 정확히 크롤링. 아니면 붙여넣은 URL 그대로. const payload = selected ? selected.place_url : input.trim() - setPhase('making'); setErr(''); setStatus('생성 준비 중…') + setPhase('making'); setErr(''); setPct(0) try { const res = await createJob({ scenario: scn, input: payload }) poll(res.id) @@ -543,14 +540,10 @@ export function CreateSheet({ open, onClose, onCreated }: {
{!err ? ( <> -
-

{s.emoji} {s.name} 썰박스 생성 중…

-

{status}
AI가 대본→그림→목소리→합성 중이에요. 보통 2~4분 걸려요.

-
-
· 시나리오  {s.name}
-
· 가게  {selected ? selected.title : input}
- {selected &&
· 위치  {selected.address || selected.roadAddress}
} - {log.length > 0 &&
{log.slice(-4).join('\n')}
} +

영상 생성 중…

+
+
+
{pct}%
) : ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 65cf650..026c845 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -255,13 +255,20 @@ ul{list-style:none} .cta:disabled{background:var(--surface-2);color:var(--faint);box-shadow:none;cursor:not-allowed} /* 생성중 상태 */ -.making{padding:30px 20px;text-align:center} -.making .spin{width:52px;height:52px;border-radius:50%;border:4px solid var(--surface-2);border-top-color:var(--sc,#dc2743);margin:0 auto 18px;animation:sp 1s linear infinite} +.making{padding:44px 24px;text-align:center} @keyframes sp{to{transform:rotate(360deg)}} .making h4{font-size:17px;font-weight:800} .making p{font-size:13px;color:var(--muted);margin-top:8px;line-height:1.6} -.making .job{margin-top:16px;background:var(--surface-2);border-radius:12px;padding:12px 14px;font-size:12.5px;text-align:left;color:var(--muted);line-height:1.7} -.making .job b{color:var(--fg)} +/* 생성 진행: 바 + 퍼센트만 */ +.making .prog{margin-top:22px;display:flex;align-items:center;gap:12px} +.making .prog-bar{flex:1;height:10px;border-radius:99px;background:var(--surface-2);overflow:hidden;position:relative} +.making .prog-fill{height:100%;border-radius:99px;background:var(--sc,#dc2743); + transition:width .5s cubic-bezier(.22,1,.36,1);position:relative;overflow:hidden} +.making .prog-fill::after{content:"";position:absolute;inset:0; + background:linear-gradient(90deg,transparent,rgba(255,255,255,.45),transparent); + animation:progsh 1.15s linear infinite} +@keyframes progsh{from{transform:translateX(-100%)}to{transform:translateX(100%)}} +.making .prog-pct{font-size:14px;font-weight:800;color:var(--fg);min-width:40px;text-align:right;font-variant-numeric:tabular-nums} /* ============================ 릴스형 뷰어 ============================ */ .viewer{position:absolute;inset:0;z-index:60;background:#000;display:none;flex-direction:column} @@ -284,9 +291,10 @@ ul{list-style:none} .vrail{position:absolute;right:10px;bottom:120px;z-index:3;display:flex;flex-direction:column;gap:20px;align-items:center} .vrail button{color:#fff;display:flex;flex-direction:column;align-items:center;gap:4px;font-size:11px;font-weight:600;font-variant-numeric:tabular-nums} .vrail svg{width:29px;height:29px;fill:#fff} -.vbottom{position:absolute;left:0;right:64px;bottom:0;z-index:3;padding:16px; - padding-bottom:calc(20px + env(safe-area-inset-bottom)); - background:linear-gradient(0deg,rgba(0,0,0,.6),transparent)} +/* 하단 제목/설명 — 네이티브 재생바(약 46px) 위로 올리고, 재생바 조작을 막지 않도록 클릭 통과 */ +.vbottom{position:absolute;left:0;right:64px;bottom:0;z-index:3;padding:16px;pointer-events:none; + padding-bottom:calc(56px + env(safe-area-inset-bottom)); + background:linear-gradient(0deg,rgba(0,0,0,.55),transparent)} .vbottom .vt{color:#fff;font-weight:800;font-size:17px;line-height:1.3} .vbottom .vn{color:rgba(255,255,255,.86);font-size:13px;margin-top:8px;line-height:1.5; display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}