Compare commits

..

3 Commits

Author SHA1 Message Date
Haewon Kam
fb9c84aa62 style(web): 무빙포스터 로고를 Playreel과 같은 세로 락업으로
ADO2 로고 옆에 워드마크를 붙이던 인라인 배치를 Playreel 셸(.pr-wordmark)과
같은 문법으로 바꾼다 — ADO2 로고가 위, 제품 워드마크가 아래, 아래 줄을 로고
폭에 맞춰 락업이 사각형으로 앉는다.

MOVING POSTER 는 PLAYREEL 보다 글자가 길어 같은 18px 를 쓰면 로고 폭을
넘는다. 브라우저에서 재서 16.4px + 자간 0.14em 으로 잡았다.
자간은 마지막 글자 뒤에도 붙으므로 음수 마진으로 걷어내야 오른쪽 끝이 맞는다.

실측(로고 h24 = 162.54px):
  락업 컨테이너 폭 162.54px — 로고와 정확히 일치
  워드마크 잉크 폭 162.34px — 오른쪽 끝 오차 0.2px
  (참고: Playreel 자체 워드마크는 같은 기준에서 2.75px 어긋나 있다)
  390px 모바일 행: 헤더 71.4px, 메뉴 가로 스크롤 유지, 잘림 없음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:04:55 +09:00
Haewon Kam
d50dc52c39 fix(web): 게이트 ① 구획 선택에 썸네일을 그린다
thumb_url을 백엔드가 내려보내는데 화면은 라벨 칩만 그렸다.
실물은 19조각에 라벨이 겹쳐서("유의사항" 일곱 개) 무엇을 고르는지
알 수 없는 상태였다.

- SectionPicker: 썸네일 그리드(auto-fill 84px, 390px에서 4열).
  번호 배지 · 선택 표시 · 잠금(캐스팅 스케줄) · 높이 툴팁
- 문구를 새 동작에 맞춘다 — 조각을 잘라 붙이는 것이 아니라
  고른 지점부터 상세페이지를 이어서 훑는다
- 목 데이터를 실측 19조각으로 교체. 6개짜리 깔끔한 목을 두었던 탓에
  라벨이 겹치는 실물 모양을 개발 중에 못 봤다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:24:58 +09:00
Haewon Kam
0e53fa294b fix(compose): 상세페이지 스크롤을 롱이미지로 되돌린다
조각을 화면에 얹는 방식이라 스크롤이 한 번도 일어나지 않았다.
심규선 상세페이지 실측 — 19조각 중 1920px를 넘는 것이 0개라
scroll_scene의 span이 항상 0이었다. 화면의 13~44%만 조각이고
나머지는 블러 포스터로 남았으며, 같은 정지컷이 6초 넘게 이어졌다.

정본(tech_spec §5)에 있던 extend_to_height가 이식에서 빠진 것이 원인이다.
그것이 없으니 조각이 짧을 때 블러 배경에 상단 정렬로 얹는 수밖에 없었다.

- to_surface: 롱이미지를 폭 1080으로. trim_blank를 태우지 않는다 —
  위를 깎으면 섹션 y좌표가 전부 어긋난다
- extend_to_height + blank_band: 글자 없는 배경 띠를 거울 타일링,
  이음매 페더 40px. 프레임보다 짧아도 풀프레임을 채운다
- scroll_scene: 배경 합성 없이 surface.crop() — 블러가 섞일 여지를 없앤다
- build_scene_plan: 섹션은 훑기 시작점, 큐 길이가 훑는 거리.
  섹션이 떨어지면 앞 씬이 멈춘 자리에서 이어 읽는다(커서)
- SCROLL_TAG_PRIORITY에서 notice·crossbanner 제거 — 약관을 읽히는 영상이 아니다
- final_checks: 씬 종류가 scroll이라고 적힌 것이 아니라 실제 이동 거리를 본다.
  이 검사가 물러서 0픽셀 스크롤이 게이트를 통과했다

실측(심규선 자산 전체 합성, 569프레임 23.72초):
  콘텐츠 비율 t=8s 23%→99% · t=11s 13%→92% · t=17s 44%→99%
  프레임 간 변화량 0.01(정지) → 22~31

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:24:58 +09:00
39 changed files with 89 additions and 1112 deletions

View File

@ -13,11 +13,6 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# 가상환경을 만들지 않고 이미지의 python에 바로 깐다.
# compose가 소스를 /app에 물리면 그 안의 .venv가 가려지고, 가려진 자리를 볼륨으로 되살리면
# 컨테이너를 다시 만들어도 옛 볼륨이 따라붙어 이미지를 새로 구워도 패키지가 안 바뀐다
ENV UV_PROJECT_ENVIRONMENT=/usr/local
# 의존성만 먼저 깔아 레이어를 캐시한다
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project
@ -25,7 +20,8 @@ RUN uv sync --frozen --no-install-project
COPY . .
RUN uv sync --frozen
ENV PYTHONUNBUFFERED=1
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1
# CLI 토큰은 컨테이너가 켜질 때 호스트의 것을 참조한다:
# -v ~/.config/higgsfield:/root/.config/higgsfield

View File

@ -26,29 +26,8 @@ uv sync
| `TYPECAST_API_KEY` | 롱컷 tts (축제는 OpenAI를 써서 필요 없다) |
| `SUNO_API_KEY` · `SUNO_CALLBACK_URL` | bgm |
| `HIGGSFIELD_ACCOUNT` | upscale · i2v 계정 가드 |
| `MYSQL_HOST` · `MYSQL_PORT` · `MYSQL_USER` · `MYSQL_PASSWORD` · `MYSQL_DB` | DB 세션 |
| `MYSQL_URL` | DB 세션 |
| `AZURE_BLOB_BASE_URL` · `AZURE_BLOB_SAS_TOKEN` | blob 업로드 |
| `GOOGLE_CLIENT_ID` | 구글 로그인. 비면 로그인만 꺼지고 서버는 뜬다 |
| `JWT_SECRET` | 세션 쿠키 서명. 바뀌면 로그인된 사람이 전부 풀린다 |
### 구글 로그인
구글 클라우드 콘솔에서 **웹 애플리케이션** OAuth 클라이언트를 만들고 승인된 자바스크립트
원본에 서비스 주소를 넣는다. 발급된 client id를 `GOOGLE_CLIENT_ID`에 넣는다.
`client_secret`은 쓰지 않는다 — 코드 교환 없이 ID 토큰만 검증한다.
`JWT_SECRET`은 아무 긴 임의 문자열이면 된다.
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
```
사용자는 처음 로그인할 때 만들어지고 잡을 `job_limit`(기본 3)개까지 만들 수 있다.
무제한으로 둘 계정은 `user` 테이블의 그 값을 올린다.
```sql
UPDATE user SET job_limit = 100000 WHERE email = 'jhyeu@o2o.kr';
```
### Docker

View File

@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from pipelines import worker
from routers import archive, auth, f1, playreel
from routers import archive, f1, playreel
from utils import blob
from utils.database import close_engine, create_missing_tables
@ -21,7 +21,6 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="poster-alive", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(f1.router)
app.include_router(playreel.router)
app.include_router(archive.router)

View File

@ -33,7 +33,6 @@ from services.split_detail import split_details
from services.tts import synthesize
from services.upscale_poster import as_png_path, upscale_poster
from tables.task import PlayreelTask
from tables.user import UNASSIGNED_USER_ID
from utils import blob
from utils.higgsfield import KLING_3_0
from utils.video import frames_at
@ -80,10 +79,10 @@ def thumbnail_url(task: PlayreelTask) -> str | None:
return artifact_url(PIPELINE, task.id, THUMBNAIL) if task.video_url else None
async def create_task(session: AsyncSession, url: str, goods_id: str, slug: str, *,
user_id: str = UNASSIGNED_USER_ID) -> PlayreelTask:
async def create_task(session: AsyncSession, url: str, goods_id: str,
slug: str) -> PlayreelTask:
task = PlayreelTask(source_url=url, goods_id=goods_id, slug=slug,
name=f"공연 {goods_id}", user_id=user_id,
name=f"공연 {goods_id}",
stage_timings=initial_timings(PlayreelState))
session.add(task)
await session.commit()

View File

@ -24,7 +24,6 @@ from services.render import render
from services.tts import synthesize
from services.upscale_poster import as_png_path
from tables.task import PosterAliveTask, new_task_id
from tables.user import UNASSIGNED_USER_ID
from utils import blob
from utils.image import sniff_extension
@ -32,12 +31,10 @@ PIPELINE = "poster_alive"
async def create_task(session: AsyncSession, name: str, poster: bytes, *,
skip_review: bool = False,
user_id: str = UNASSIGNED_USER_ID) -> PosterAliveTask:
skip_review: bool = False) -> PosterAliveTask:
# poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다.
# 그 경로에 id가 필요하므로 여기서 미리 만든다
task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review,
user_id=user_id,
stage_timings=initial_timings(PosterAliveState))
with Image.open(io.BytesIO(poster)) as image:
task.poster_width, task.poster_height = image.size

View File

@ -11,7 +11,6 @@ dependencies = [
"openai>=3.6.0",
"pillow>=12.3.0",
"pydantic-settings>=2.15.0",
"python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.32",
"scipy>=1.18.1",
"sqlalchemy>=2.0.52",

View File

@ -15,7 +15,6 @@ from routers.view import UNNAMED
from tables.task import PlayreelTask, PosterAliveTask
from utils.database import get_session
# 완성본 구경은 로그인 없이 연다
router = APIRouter(prefix="/api/archive", tags=["archive"])

View File

@ -1,113 +0,0 @@
"""/api/auth — 구글 로그인과 세션
브라우저가 구글에서 받아온 credential을 검증하고, 뒤로는 자체 쿠키를 쓴다.
"""
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from tables.user import User
from utils import session_token
from utils.database import get_session
from utils.google_identity import (GoogleLoginDisabled, GoogleTokenInvalid, is_enabled,
verify_id_token)
router = APIRouter(prefix="/api/auth", tags=["auth"])
class GoogleLoginBody(BaseModel):
credential: str
def user_view(user: User) -> dict:
"""구글 sub는 내보내지 않는다 — 화면이 쓸 일이 없다"""
return {
"email": user.email,
"name": user.name,
"picture": user.picture_url,
"jobs_created": user.jobs_created,
"job_limit": user.job_limit,
"jobs_left": user.jobs_left,
}
async def find_or_create(session: AsyncSession, account) -> User:
user = await session.get(User, account.sub)
if user is None:
user = User(id=account.sub, email=account.email, name=account.name,
picture_url=account.picture)
session.add(user)
else:
# 이메일·이름·사진은 구글 쪽에서 바뀔 수 있어 로그인할 때마다 맞춘다
user.email, user.name, user.picture_url = (account.email, account.name,
account.picture)
await session.commit()
return user
@router.get("/config")
async def config():
"""프론트가 버튼을 그릴지 정하는 데 쓴다. client_id는 비밀이 아니다"""
from settings import settings
return {"enabled": is_enabled(), "client_id": settings.google_client_id}
@router.post("/google")
async def google_login(body: GoogleLoginBody, response: Response,
session: AsyncSession = Depends(get_session)):
try:
account = await verify_id_token(body.credential)
except GoogleLoginDisabled:
raise HTTPException(503, "구글 로그인이 설정되지 않았습니다")
except GoogleTokenInvalid as failure:
raise HTTPException(401, str(failure))
user = await find_or_create(session, account)
response.set_cookie(
session_token.COOKIE_NAME, session_token.issue(user.id),
max_age=session_token.cookie_max_age(),
httponly=True, samesite="lax", path="/",
)
return user_view(user)
@router.get("/me")
async def me(request: Request, session: AsyncSession = Depends(get_session)):
user = await optional_user(request, session)
if user is None:
raise HTTPException(401, "로그인이 필요합니다")
return user_view(user)
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(session_token.COOKIE_NAME, path="/")
return {"ok": True}
async def optional_user(request: Request, session: AsyncSession) -> User | None:
token = request.cookies.get(session_token.COOKIE_NAME)
if not token:
return None
try:
user_id = session_token.read(token)
except session_token.SessionInvalid:
return None
return await session.get(User, user_id)
async def current_user(request: Request,
session: AsyncSession = Depends(get_session)) -> User:
"""로그인이 필요한 엔드포인트가 의존성으로 받는다"""
user = await optional_user(request, session)
if user is None:
raise HTTPException(401, "로그인이 필요합니다")
return user
async def creating_user(user: User = Depends(current_user)) -> User:
"""잡을 만드는 엔드포인트용 — 한도를 여기서 막는다"""
if not user.can_create_job:
raise HTTPException(
403, f"만들 수 있는 개수를 다 썼습니다 ({user.jobs_created}/{user.job_limit})")
return user

View File

@ -11,17 +11,13 @@ from models.pipeline_state import PosterAliveState
from pipelines import poster_alive
from pipelines.artifact import load_image
from pipelines.runner import AWAITING_REVIEW, FAILED, QUEUED, RUNNING, reset_from
from routers.auth import creating_user, current_user
from routers.view import motion_elements, poster_alive_view
from tables.user import User
from services.i2v import MIN_LONG_EDGE_PX
from services.motion import plan_motion
from tables.task import PosterAliveTask
from utils.database import get_session
# 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다
router = APIRouter(prefix="/api/f1", tags=["f1"],
dependencies=[Depends(current_user)])
router = APIRouter(prefix="/api/f1", tags=["f1"])
ACCEPTED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
@ -64,7 +60,6 @@ async def rewrite_motion_plan(task: PosterAliveTask, motions: list[str]) -> None
@router.post("/jobs")
async def create(poster: UploadFile = File(...), name: str = Form(""),
auto: bool = Form(False),
user: User = Depends(creating_user),
session: AsyncSession = Depends(get_session)):
if poster.content_type not in ACCEPTED_TYPES:
raise HTTPException(415, f"지원 형식: JPG/PNG/WEBP/GIF (받은 것: {poster.content_type})")
@ -86,21 +81,14 @@ async def create(poster: UploadFile = File(...), name: str = Form(""),
f"(받은 것: {width}x{height}). 고해상 원본으로 올려주세요.")
# 이름을 비워 두면 narration_text 가 포스터에서 읽은 행사명으로 채운다
task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto,
user_id=user.id)
# 잡을 지워도 회복되지 않게 누적값을 올린다
user.jobs_created += 1
await session.commit()
task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto)
return {"id": task.id, "ahead": 0}
@router.get("/jobs")
async def list_jobs(user: User = Depends(current_user),
session: AsyncSession = Depends(get_session)):
"""자기가 만든 것만 돌려준다"""
async def list_jobs(session: AsyncSession = Depends(get_session)):
found = await session.scalars(
select(PosterAliveTask).where(PosterAliveTask.user_id == user.id)
.order_by(PosterAliveTask.created_at.desc()))
select(PosterAliveTask).order_by(PosterAliveTask.created_at.desc()))
return [poster_alive_view(task) for task in found]

View File

@ -11,15 +11,11 @@ from pipelines import playreel
from pipelines.gate import (REVERSIBLE_GATES, PlayreelGate, apply_playreel_edits,
previous_gate, resume_stage)
from pipelines.runner import AWAITING_REVIEW, DONE, FAILED, QUEUED, RUNNING, reset_from
from routers.auth import creating_user, current_user
from routers.view import playreel_view
from tables.task import PlayreelTask
from tables.user import User
from utils.database import get_session
# 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다
router = APIRouter(prefix="/api/playreel", tags=["playreel"],
dependencies=[Depends(current_user)])
router = APIRouter(prefix="/api/playreel", tags=["playreel"])
# 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다
GOODS_ID_PATTERNS = (
@ -60,8 +56,7 @@ async def find_task(session: AsyncSession, task_id: str) -> PlayreelTask:
@router.post("/jobs")
async def create(body: CreateBody, user: User = Depends(creating_user),
session: AsyncSession = Depends(get_session)):
async def create(body: CreateBody, session: AsyncSession = Depends(get_session)):
goods_id = parse_goods_id(body.url)
if not goods_id:
raise HTTPException(400, "지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어 주세요.")
@ -69,21 +64,14 @@ async def create(body: CreateBody, user: User = Depends(creating_user),
if not SLUG_PATTERN.fullmatch(slug):
raise HTTPException(400, "slug는 영문 소문자·숫자·밑줄만 씁니다")
task = await playreel.create_task(session, body.url.strip(), goods_id, slug,
user_id=user.id)
# 잡을 지워도 회복되지 않게 누적값을 올린다
user.jobs_created += 1
await session.commit()
task = await playreel.create_task(session, body.url.strip(), goods_id, slug)
return {"id": task.id}
@router.get("/jobs")
async def list_jobs(user: User = Depends(current_user),
session: AsyncSession = Depends(get_session)):
"""자기가 만든 것만 돌려준다"""
async def list_jobs(session: AsyncSession = Depends(get_session)):
found = await session.scalars(
select(PlayreelTask).where(PlayreelTask.user_id == user.id)
.order_by(PlayreelTask.created_at.desc()))
select(PlayreelTask).order_by(PlayreelTask.created_at.desc()))
return [playreel_view(task) for task in found]

View File

@ -53,8 +53,7 @@ NOL_WORDMARK = ASSET_DIR / "nol_wordmark_white.png"
# 큐 순서대로 채울 씬. 앞 둘은 클립, 마지막은 포스터, 나머지는 이 태그 우선순위로 고른다.
CLIP_CUES = 2
# 여기 없는 태그는 시작점이 안 된다 — 유의사항·제작진·타 공연 배너·조각은 읽힐 내용이
# 아니고, 키비주얼은 앞 큐의 포스터와 겹친다. 관람 정보는 러닝타임·관람등급이라 남긴다
# 공지·교차배너는 스크롤 시작점으로 쓰지 않는다 — 약관을 읽히려고 만드는 영상이 아니다
SCROLL_TAG_PRIORITY = ("synopsis", "still", "cast", "schedule", "discount", "event", "info")
REQUIRED_TAGS = ("schedule",) # 캐스팅 스케줄은 예매 전에 확인하는 정보라 빠지면 안 된다
@ -243,21 +242,13 @@ def scroll_starts(sections: Sequence[DetailSection],
return ordered
def scroll_window(surface: Image.Image, start_px: int, seconds: float) -> tuple[float, float]:
"""시작 픽셀과 큐 길이로 훑을 구간을 정한다. 롱이미지 높이 대비 비율로 돌려준다.
여기서 crop 가능한 범위로 묶는다 플랜에 남는 값이 실제 이동량이어야
게이트가 0픽셀 스크롤을 걸러낼 있다.
"""
span = max(0, surface.height - H)
start = min(max(0, start_px), span)
end = min(start + round(seconds * SCROLL_SCREENS_PER_SECOND * H), span)
return start / surface.height, end / surface.height
def section_start_px(section: DetailSection, source: Image.Image) -> int:
"""섹션 y좌표는 원본 롱이미지 기준이라 폭 1080 환산 배율을 곱한다."""
return round(section.y0 * W / source.width)
def scroll_window(section: DetailSection, source: Image.Image, surface: Image.Image,
seconds: float) -> tuple[float, float]:
"""섹션이 시작점, 큐 길이가 훑는 거리. 둘 다 롱이미지 높이 대비 비율로 돌려준다."""
scale = W / source.width
start = round(section.y0 * scale)
reach = round(seconds * SCROLL_SCREENS_PER_SECOND * H)
return start / surface.height, (start + reach) / surface.height
def build_scene_plan(sections: Sequence[DetailSection], spans: Sequence[tuple[float, float]],
@ -269,9 +260,7 @@ def build_scene_plan(sections: Sequence[DetailSection], spans: Sequence[tuple[fl
scenes: list[Scene] = []
index = 0
# 롱이미지마다 어디까지 읽었는지 픽셀로 따로 센다. 비율은 이미지 높이가 다르면
# 서로 다른 자리를 가리키므로 이어 읽기 지점으로 쓸 수 없다
read_to: dict[str, int] = {}
cursor = 0.0 # 폴백이 이어 읽을 지점
for cue, (start, end) in enumerate(spans):
if cue < CLIP_CUES:
scenes.append(Scene(cue=cue, kind="clip"))
@ -279,21 +268,19 @@ def build_scene_plan(sections: Sequence[DetailSection], spans: Sequence[tuple[fl
scenes.append(Scene(cue=cue, kind="poster"))
elif index < len(ordered):
section = ordered[index]
surface = surfaces[section.source]
y0, y1 = scroll_window(surface,
section_start_px(section, sources[section.source]),
end - start)
y0, y1 = scroll_window(section, sources[section.source],
surfaces[section.source], end - start)
scenes.append(Scene(cue=cue, kind="scroll", section=section.name,
tag=section.tag, source=section.source, y0=y0, y1=y1))
index = index + 1
read_to[section.source] = round(y1 * surface.height)
index, cursor = index + 1, y1
elif fallback is not None:
# 쓸 섹션이 떨어져도 포스터로 때우지 않는다. 그 롱이미지를 읽던 자리에서
# 이어 읽어야 같은 화면이 두 번 나오지 않는다
surface = surfaces[fallback]
y0, y1 = scroll_window(surface, read_to.get(fallback, 0), end - start)
scenes.append(Scene(cue=cue, kind="scroll", source=fallback, y0=y0, y1=y1))
read_to[fallback] = round(y1 * surface.height)
# 쓸 섹션이 떨어져도 포스터로 때우지 않는다. 앞 씬이 멈춘 자리에서 이어 읽어야
# 같은 화면이 두 번 나오지 않는다
reach = round((end - start) * SCROLL_SCREENS_PER_SECOND * H)
y1 = cursor + reach / surfaces[fallback].height
scenes.append(Scene(cue=cue, kind="scroll", source=fallback,
y0=cursor, y1=y1))
cursor = y1
else:
scenes.append(Scene(cue=cue, kind="poster"))
return LongcutPlan(scenes=scenes)

View File

@ -30,8 +30,6 @@ POSTER_W = 1000 # 9:16 안에서 포스터가 차지할 폭 (좌우
TOP_PAD = 86
LOOP_OVERLAP = 16 # 겹침 프레임. 소재가 잔잔할수록 키워야 이음매가 죽는다
MIN_LOOP_SECONDS = 3
# 한 프레임 이내로 넘치는 것은 잘려도 들리지 않는다. 이게 없으면 같은 초에서 실패한다
NARRATION_OVERFLOW_TOLERANCE = 1 / FPS
NARRATION_VOL, SFX_VOL, BGM_VOL = 1.45, 0.35, 0.20
BGM_FADE_IN = 1.0
@ -262,8 +260,8 @@ def render(clip: bytes, poster: Path | Image.Image, *,
raise RuntimeError(f"루프 길이가 너무 짧다 ({loop_length}프레임) — overlap을 줄일 것")
duration = loop_length / FPS
if timeline and timeline.cues[-1].end > duration + NARRATION_OVERFLOW_TOLERANCE:
raise RuntimeError(f"나레이션이 {timeline.cues[-1].end:.3f}초로 영상 {duration:.3f}초를 "
if timeline and timeline.cues[-1].end > duration:
raise RuntimeError(f"나레이션이 {timeline.cues[-1].end:.2f}초로 영상 {duration:.2f}초를 "
"넘는다 — overlap을 줄이거나 나레이션을 짧게")
# 2회차 — 순서대로 합성해 인코더에 바로 밀어넣는다

View File

@ -21,12 +21,6 @@ class Settings(BaseSettings):
azure_blob_base_url: str = ""
azure_blob_sas_token: str = ""
# 비어 있으면 구글 로그인이 꺼진다. 값은 비밀이 아니라 프론트 번들에도 들어간다
google_client_id: str = ""
# 자체 세션 토큰 서명 키. 바뀌면 로그인된 사람이 전부 풀린다
jwt_secret: str = ""
jwt_days: int = 14
@property
def mysql_url(self) -> str:
password = quote_plus(self.mysql_password)

View File

@ -9,7 +9,6 @@ from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, Integer, String, Te
from sqlalchemy.orm import Mapped, mapped_column
from models.pipeline_state import PlayreelState, PosterAliveState
from tables.user import UNASSIGNED_USER_ID, USER_ID_LENGTH
from utils.database import Base
URL_LENGTH = 512
@ -32,9 +31,6 @@ class TaskBase(Base):
id: Mapped[str] = mapped_column(String(TASK_ID_LENGTH), primary_key=True,
default=new_task_id)
# 만든 사람의 구글 sub. 모르면 UNASSIGNED_USER_ID가 들어간다
user_id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), index=True,
default=UNASSIGNED_USER_ID)
name: Mapped[str] = mapped_column(String(255), default="")
status: Mapped[str] = mapped_column(String(32), default="queued", index=True)

View File

@ -1,45 +0,0 @@
"""구글 계정으로 로그인한 사람
id가 구글 sub다. 이메일이 바뀌어도 유지되는 값이라 따로 발급하지 않는다.
생성 횟수는 행을 세지 않고 누적값을 올린다 잡을 지워도 회복되지 않는다.
"""
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from utils.database import Base
USER_ID_LENGTH = 64
EMAIL_LENGTH = 320
PICTURE_URL_LENGTH = 512
DEFAULT_JOB_LIMIT = 3
# 소유자를 모르는 잡의 자리. 나중에 admin의 sub로 덮어쓴다
UNASSIGNED_USER_ID = "0"
class User(Base):
__tablename__ = "user"
id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), primary_key=True)
email: Mapped[str] = mapped_column(String(EMAIL_LENGTH), index=True)
name: Mapped[str] = mapped_column(String(255), default="")
# 구글 프로필 사진. 토큰에만 들어오는 값이라 로그인할 때 받아 둔다
picture_url: Mapped[str] = mapped_column(String(PICTURE_URL_LENGTH), default="")
# 만들 수 있는 총 잡 수. 무제한으로 둘 계정은 이 값을 크게 올린다
job_limit: Mapped[int] = mapped_column(Integer, default=DEFAULT_JOB_LIMIT)
jobs_created: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
last_login_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(),
onupdate=func.now())
@property
def can_create_job(self) -> bool:
return self.jobs_created < self.job_limit
@property
def jobs_left(self) -> int:
return max(0, self.job_limit - self.jobs_created)

View File

@ -1,5 +1,6 @@
"""MySQL 비동기 세션
FastAPI 의존성으로 받아
"""MySQL 비동기 세션.
FastAPI 의존성으로 쓴다:
async def handler(session: AsyncSession = Depends(get_session)): ...
"""
from collections.abc import AsyncGenerator

View File

@ -1,118 +0,0 @@
"""구글 ID 토큰 검증
브라우저가 받아온 토큰이 구글이 우리 앞으로 발급한 것인지만 본다
코드 교환을 하지 않아 client_secret이 없다 검증에 필요한 것은 공개키와 client_id뿐이다.
검증을 통과하면 뒤로는 우리 토큰을 쓴다. 구글 토큰을 세션으로 들고 다니지 않는다.
빠지면 되는 검사
서명 구글 JWKS 공개키. 없으면 아무나 만든 JSON이 통과한다
aud 우리 client_id. 없으면 다른 앞으로 발급된 진짜 구글 토큰이 통과한다
iss accounts.google.com
"""
import asyncio
import time
import httpx
from jose import JWTError, jwt
from settings import settings
JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
# 구글이 두 표기를 다 쓴다. 한쪽만 받으면 어느 날 전원 로그인 실패가 된다
ISSUERS = ("accounts.google.com", "https://accounts.google.com")
JWKS_TTL_SECONDS = 3600
HTTP_TIMEOUT = 5.0
_jwks: dict | None = None
_jwks_fetched_at = 0.0
# 토큰이 동시에 여러 개 들어와도 JWKS는 한 번만 받는다
_jwks_lock = asyncio.Lock()
class GoogleLoginDisabled(RuntimeError):
"""GOOGLE_CLIENT_ID 없음 — 구글 로그인만 꺼지고 서버는 뜬다"""
class GoogleTokenInvalid(RuntimeError):
"""서명·aud·iss·만료 중 하나가 어긋남"""
class GoogleAccount:
def __init__(self, sub: str, email: str, name: str, picture: str):
self.sub = sub
self.email = email
self.name = name
self.picture = picture
def is_enabled() -> bool:
return bool(settings.google_client_id)
async def fetch_jwks() -> dict:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
response = await client.get(JWKS_URL)
response.raise_for_status()
return response.json()
async def get_jwks(*, force: bool = False) -> dict:
global _jwks, _jwks_fetched_at
async with _jwks_lock:
fresh = _jwks is not None and (time.monotonic() - _jwks_fetched_at) < JWKS_TTL_SECONDS
if fresh and not force:
return _jwks
try:
_jwks = await fetch_jwks()
_jwks_fetched_at = time.monotonic()
except Exception as failure:
print(f"[google] JWKS 조회 실패: {failure}", flush=True)
# 낡은 캐시라도 있으면 그걸로 간다. 구글이 잠깐 안 될 때 로그인이 통째로 죽는 것보다 낫다
if _jwks is None:
raise GoogleTokenInvalid("구글 공개키를 받지 못했습니다") from failure
return _jwks
def has_key(jwks: dict, kid: str | None) -> bool:
return any(key.get("kid") == kid for key in jwks.get("keys") or [])
async def verify_id_token(credential: str) -> GoogleAccount:
if not is_enabled():
raise GoogleLoginDisabled("GOOGLE_CLIENT_ID 없음")
if not credential:
raise GoogleTokenInvalid("빈 토큰")
try:
kid = jwt.get_unverified_header(credential).get("kid")
except JWTError as failure:
raise GoogleTokenInvalid("토큰 형식이 아님") from failure
jwks = await get_jwks()
# 공개키는 주기적으로 바뀐다. 캐시에 없는 kid면 한 번만 다시 받는다
if not has_key(jwks, kid):
jwks = await get_jwks(force=True)
try:
claims = jwt.decode(
credential, jwks, algorithms=["RS256"],
audience=settings.google_client_id, issuer=ISSUERS,
# at_hash는 access_token과 짝일 때만 의미가 있다. 브라우저가 주는
# 크리덴셜에는 access_token이 없어 켜 두면 정상 토큰이 거부된다
options={"verify_at_hash": False},
)
except JWTError as failure:
# 사유는 로그에만 남긴다
print(f"[google] ID 토큰 거부: {failure}", flush=True)
raise GoogleTokenInvalid("구글 토큰이 유효하지 않습니다") from failure
sub = str(claims.get("sub") or "")
if not sub:
raise GoogleTokenInvalid("sub 없음")
# 미인증 이메일은 신원으로 쓸 수 없다
if not claims.get("email_verified"):
raise GoogleTokenInvalid("이메일이 인증되지 않은 계정입니다")
return GoogleAccount(sub=sub, email=str(claims.get("email") or ""),
name=str(claims.get("name") or ""),
picture=str(claims.get("picture") or ""))

View File

@ -1,41 +0,0 @@
"""자체 세션 토큰
구글 검증을 통과한 뒤로는 이걸 HttpOnly 쿠키로 들고 다닌다
담는 것은 user.id 하나다. 이메일·이름은 바뀔 있어 토큰에 넣지 않고 매번 DB에서 읽는다.
"""
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from settings import settings
ALGORITHM = "HS256"
COOKIE_NAME = "poster_alive_session"
class SessionInvalid(RuntimeError):
"""서명이 안 맞거나 만료됨"""
def issue(user_id: str) -> str:
if not settings.jwt_secret:
raise RuntimeError("JWT_SECRET 없음 — 세션을 발급할 수 없습니다")
expires = datetime.now(timezone.utc) + timedelta(days=settings.jwt_days)
return jwt.encode({"sub": user_id, "exp": expires}, settings.jwt_secret,
algorithm=ALGORITHM)
def read(token: str) -> str:
"""토큰에서 user.id를 꺼낸다"""
try:
claims = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
except JWTError as failure:
raise SessionInvalid("세션이 유효하지 않습니다") from failure
user_id = str(claims.get("sub") or "")
if not user_id:
raise SessionInvalid("sub 없음")
return user_id
def cookie_max_age() -> int:
return settings.jwt_days * 24 * 3600

181
backend/uv.lock generated
View File

@ -95,7 +95,6 @@ dependencies = [
{ name = "openai" },
{ name = "pillow" },
{ name = "pydantic-settings" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "scipy" },
{ name = "sqlalchemy" },
@ -112,7 +111,6 @@ requires-dist = [
{ name = "openai", specifier = ">=3.6.0" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "pydantic-settings", specifier = ">=2.15.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.32" },
{ name = "scipy", specifier = ">=1.18.1" },
{ name = "sqlalchemy", specifier = ">=2.0.52" },
@ -128,65 +126,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@ -196,68 +135,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]]
name = "fastapi"
version = "0.141.1"
@ -573,24 +450,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@ -670,25 +529,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
@ -724,18 +564,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "scipy"
version = "1.18.1"
@ -787,15 +615,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"

View File

@ -4,14 +4,13 @@ services:
backend:
build: ./backend
ports:
- "8765:8765"
- "30101:30101"
env_file:
- backend/.env
volumes:
# 소스를 그대로 물린다 — 호스트에서 고치고 exec으로 바로 돌린다. 산출물도 호스트에 남는다.
# 가상환경은 이미지의 /opt/venv에 있어 이 마운트에 가려지지 않는다.
- ./backend:/app
- /app/.venv # 컨테이너 안에서 만든 것을 덮지 않는다
# Higgsfield CLI 토큰을 호스트에서 참조한다. 만료 시 갱신해야 해서 읽기 전용이 아니다.
- ${HOME}/.config/higgsfield:/root/.config/higgsfield
restart: unless-stopped

View File

@ -1,14 +0,0 @@
# COPY . . 가 호스트 것을 가져오면 npm ci 로 깐 것을 덮어쓰고 빌드도 느려진다
node_modules
.next
out
build
.git
.gitignore
.env
.env.*
npm-debug.log*
*.tsbuildinfo
.DS_Store

View File

@ -1,8 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import Ado2Logo from "@/components/ado2-logo";
import AccountBadge from "@/components/account-badge";
import AuthGate from "@/components/auth-gate";
import NavLink from "@/components/nav-link";
import { FEATURES } from "@/lib/features";
@ -16,9 +14,9 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
<>
<aside className="sidebar">
<div className="sidebar-logo">
<Link href="/" style={{ color: "var(--color-text-white)", display: "inline-flex", alignItems: "baseline", gap: 8, textDecoration: "none" }}>
<Ado2Logo height={20} />
<span className="sidebar-product">MOVING POSTER</span>
<Link href="/" className="mp-wordmark">
<Ado2Logo height={24} />
<span className="mp-mark">MOVING POSTER</span>
</Link>
</div>
<nav className="sidebar-menu">
@ -26,13 +24,10 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
{FEATURES.styling && <NavLink href="/studio" icon="image"> </NavLink>}
{FEATURES.archive && <NavLink href="/archive" icon="folder"></NavLink>}
</nav>
<AccountBadge />
<div className="sidebar-foot"> · </div>
</aside>
<main className="main-content">
<div style={{ maxWidth: 1080, margin: "0 auto" }}>
<AuthGate>{children}</AuthGate>
</div>
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div>
</main>
</>
);

View File

@ -2,10 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { apiFetch, type Job } from "@/lib/api";
import { useMe } from "@/lib/auth";
import { FEATURES } from "@/lib/features";
import { GATE_META, type PlayreelJob } from "@/lib/playreel";
/* 무빙포스터 진입 — 두 갈래 (MOVING_POSTER_ENTRY_PLAN.md §1·§5) */
@ -15,14 +12,14 @@ const ENTRIES = [
href: "/poster", icon: "IMG", title: "이미지로 시작하기",
desc: "포스터 한 장이 첫 프레임 그대로 살아나는 8~15초 무빙포스터",
flow: ["포스터 원본이 첫 프레임", "불꽃·물결·조명 등 요소만 움직임", "제목·일시 나레이션 + 음악"],
facts: [["필요한 것", "이미지 1장"], ["확인", "1회"], ["시간", "약 3분"]],
facts: [["필요한 것", "이미지 1장"], ["확인", "1회"], ["시간", "약 3분"], ["비용", "14크레딧"]],
cta: "포스터 올리기", hot: false, badge: null,
},
{
href: "/playreel", icon: "URL", title: "공연상품페이지로 시작하기",
desc: "상품페이지 주소 하나로 캐스팅·일정·줄거리까지 담긴 30초 예고편",
flow: ["포스터 무빙 훅 8초", "상세페이지 스크롤 (줄거리·캐스트·캐스팅 스케줄·할인)", "예매 안내 밴드 + QR"],
facts: [["필요한 것", "상품페이지 URL"], ["확인", "5회"], ["시간", "약 15분"]],
facts: [["필요한 것", "상품페이지 URL"], ["확인", "5회"], ["시간", "약 15분"], ["비용", "16크레딧"]],
cta: "주소 넣기", hot: true, badge: "상세페이지까지",
},
] as const;
@ -38,14 +35,10 @@ function tone(status: string) {
}
export default function HomePage() {
const router = useRouter();
const { me } = useMe();
const [recent, setRecent] = useState<Recent[]>([]);
useEffect(() => {
// 잡 목록은 로그인해야 볼 수 있다. 로그인 전에는 부르지 않는다
if (!me) return;
// 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽이 비어도 다른 쪽은 보인다.
// 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽 API가 없어도(playreel 미구현) 다른 쪽은 보인다.
Promise.all([
apiFetch<Job[]>("/api/f1/jobs").catch(() => [] as Job[]),
apiFetch<PlayreelJob[]>("/api/playreel/jobs").catch(() => [] as PlayreelJob[]),
@ -57,7 +50,7 @@ export default function HomePage() {
];
setRecent(a.sort((x, y) => y.t - x.t).slice(0, 6));
});
}, [me]);
}, []);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "1.5rem" }}>
@ -88,18 +81,7 @@ export default function HomePage() {
<div key={k}><b>{k}</b><span>{v}</span></div>
))}
</div>
{e.href === "/playreel" && !FEATURES.playreelEntry ? (
<button className="btn-cta btn-lg" disabled> </button>
) : (
/* . Link
*/
<Link href={e.href} className="btn-cta btn-lg" style={{ textDecoration: "none" }}
onClick={(event) => {
if (me !== false) return;
event.preventDefault();
router.push(`/login?next=${encodeURIComponent(e.href)}`);
}}>{e.cta}</Link>
)}
<Link href={e.href} className="btn-cta btn-lg" style={{ textDecoration: "none" }}>{e.cta}</Link>
</div>
))}
</div>

View File

@ -4,14 +4,10 @@ import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import PosterDropzone from "@/components/poster-dropzone";
import { useRequireLogin } from "@/components/auth-gate";
import { apiFetch } from "@/lib/api";
import { refreshMe } from "@/lib/auth";
import { FEATURES } from "@/lib/features";
export default function HomePage() {
const router = useRouter();
const requireLogin = useRequireLogin();
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
@ -19,8 +15,6 @@ export default function HomePage() {
const submit = async () => {
if (!file) return;
// 여기서 처음으로 로그인이 필요해진다
if (!requireLogin()) return;
setBusy(true);
setError(null);
try {
@ -28,8 +22,6 @@ export default function HomePage() {
fd.append("poster", file);
fd.append("name", name);
const { id } = await apiFetch<{ id: string }>("/api/f1/jobs", { method: "POST", body: fd });
// 남은 개수가 하나 줄었다
refreshMe();
router.push(`/poster/${id}`);
} catch (e) {
setError((e as Error).message);
@ -53,11 +45,9 @@ export default function HomePage() {
/>
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", textAlign: "center", margin: 0 }}>{error}</p>}
<button className="btn-cta btn-lg" disabled={!file || busy} onClick={submit}>
{busy ? "업로드 중…" : "숏폼 만들기"}
{busy ? "업로드 중…" : <> <span className="btn-sub">14</span></>}
</button>
{FEATURES.playreelEntry && (
<p className="note"> ? <Link href="/playreel"> · </Link></p>
)}
<p className="note"> ? <Link href="/playreel"> · </Link></p>
</div>
</div>

View File

@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import Ado2Logo from "@/components/ado2-logo";
import AuthGate from "@/components/auth-gate";
import NavLink from "@/components/nav-link";
import { FEATURES } from "@/lib/features";
@ -39,9 +38,7 @@ export default function PlayreelLayout({ children }: { children: React.ReactNode
<div className="sidebar-foot">Playreel · an ADO2 product · </div>
</aside>
<main className="main-content">
<div style={{ maxWidth: 1080, margin: "0 auto" }}>
<AuthGate>{children}</AuthGate>
</div>
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div>
</main>
</>
);

View File

@ -108,6 +108,7 @@ export default function PlayreelJobPage({ params }: { params: Promise<{ id: stri
job.status === "running" ? (RUNNING_COPY[job.stage ?? ""] ?? "만드는 중") :
job.status === "queued" ? `대기 중 · 앞에 ${job.queue_size ?? 0}` :
job.status === "failed" ? "작업이 중단되었습니다" : "아카이브에 저장되었습니다"}
{job.credits_used > 0 && <span style={{ color: "var(--color-text-gray-500)" }}> · {job.credits_used}</span>}
</p>
<div className="card" style={{ marginTop: "2rem", padding: "var(--spacing-page-md)" }}>

View File

@ -66,6 +66,7 @@ function PlayreelArchivePageInner() {
</p>
<p style={{ margin: "0.3rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>
{e.version ? `v${e.version}` : "—"}
{e.credits_used != null && ` · ${e.credits_used}크레딧`}
</p>
{e.video_url && (
<a href={e.video_url} download className="btn-outline" style={{ display: "block", textAlign: "center", marginTop: "0.6rem", padding: "0.5rem", fontSize: "var(--text-sm)", textDecoration: "none" }}>

View File

@ -6,8 +6,6 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { GATE_META, parseGoodsId, type PlayreelJob } from "@/lib/playreel";
import { AutoApprovePanel } from "@/components/auto-approve";
import { useRequireLogin } from "@/components/auth-gate";
import { refreshMe, useMe } from "@/lib/auth";
const STATUS_LABEL: Record<string, string> = {
queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료",
@ -22,8 +20,6 @@ function statusTone(s: string) {
export default function PlayreelStartPage() {
const router = useRouter();
const requireLogin = useRequireLogin();
const { me } = useMe();
const [url, setUrl] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -31,17 +27,13 @@ export default function PlayreelStartPage() {
// 독립 서비스가 되면서 ADO2 홈의 "최근 작업"을 잃는다. 여기서 대신 보여준다.
const [recent, setRecent] = useState<PlayreelJob[]>([]);
useEffect(() => {
// 잡 목록은 로그인해야 볼 수 있다. 로그인 전에는 부르지 않는다
if (!me) return;
apiFetch<PlayreelJob[]>("/api/playreel/jobs")
.then((j) => setRecent(j.slice(0, 6)))
.catch(() => setRecent([]));
}, [me]);
}, []);
const submit = async () => {
if (!goodsId) return;
// 여기서 처음으로 로그인이 필요해진다
if (!requireLogin()) return;
setBusy(true);
setError(null);
try {
@ -50,8 +42,6 @@ export default function PlayreelStartPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim() }),
});
// 남은 개수가 하나 줄었다
refreshMe();
router.push(`/playreel/${id}`);
} catch (e) {
setError((e as Error).message);
@ -111,7 +101,7 @@ export default function PlayreelStartPage() {
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--color-mint)", fontWeight: 700 }}>{g.step}</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-sm)", fontWeight: 700 }}>{g.name}</p>
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>
{g.eta}
{g.credits > 0 ? `${g.credits}크레딧` : "무료"} · {g.eta}
</p>
</div>
);

View File

@ -103,59 +103,6 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
z-index: 50;
}
.sidebar-logo { padding: 1.5rem 1.5rem 0.5rem; }
/* 사이드바 맨 아래 계정 카드 */
.account-card {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0 0.75rem;
padding: 0.7rem 0.8rem;
border-radius: var(--radius-md);
background: var(--color-bg-darker);
border: 1px solid var(--color-border-white-10);
min-width: 0;
}
.account-photo {
flex: none;
width: 34px;
height: 34px;
border-radius: var(--radius-full);
object-fit: cover;
background: var(--color-bg-dark);
}
.account-photo--blank {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--color-mint);
}
.account-body { flex: 1; min-width: 0; }
.account-name {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
font-weight: 700;
}
.account-credit {
margin: 2px 0 0;
font-size: var(--text-xs);
color: var(--color-text-gray-400);
}
.account-link {
flex: none;
padding: 0;
border: none;
background: none;
cursor: pointer;
font-family: var(--font);
font-size: var(--text-xs);
color: var(--color-mint);
}
.account-link:hover { text-decoration: underline; }
.sidebar-menu { flex: 1; padding: 0 0.75rem; margin-top: 1rem; overflow-y: auto; }
.sidebar-item {
display: flex;
@ -472,6 +419,19 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
.note a { color: var(--color-mint); }
.sidebar-product { font-size: 10px; font-weight: 800; letter-spacing: 0.1em; color: var(--color-mint); white-space: nowrap; }
/* (ado2) 워드마크 Playreel 락업(.pr-wordmark) 같은 문법
ADO2 로고 · 제품 워드마크 아래. 아래 줄을 로고 폭에 맞춰 락업이 사각형으로 앉는다.
로고 폭은 height × 149/22 이므로 h24 = 162.5px. MOVING POSTER PLAYREEL 보다
글자가 길어 자간·크기를 폭에 맞게 따로 잡았다(실측으로 맞춤). */
.mp-wordmark { display: inline-flex; flex-direction: column; gap: 6px; text-decoration: none; align-items: flex-start; }
.mp-wordmark svg { color: var(--color-text-gray-300); }
.mp-mark {
/* 16.4px + 0.14em 에서 글자 잉크 폭이 162.3px 로고(h24 = 162.5px) 0.2px 차이다.
자간은 마지막 글자 뒤에도 붙으므로 그만큼 음수 마진으로 걷어내야 오른쪽 끝이 맞는다. */
font-size: 16.4px; font-weight: 800; letter-spacing: 0.14em; margin-right: -0.14em;
color: var(--color-text-white); line-height: 1; white-space: nowrap;
}
.entry-stack { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 560px; margin-top: 1.5rem; }
.entry { display: flex; flex-direction: column; gap: 0.85rem; padding: 1.25rem; position: relative; }
.entry--hot { border-color: var(--color-mint-30); }
@ -524,6 +484,7 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
.sidebar-menu { flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.sidebar-menu::-webkit-scrollbar { display: none; }
.sidebar-product { display: none; }
/* 워드마크는 남긴다 — Playreel 셸(.pr-mark)과 같은 처리 */
.playreel-steps { grid-template-columns: repeat(2, 1fr); }
.result-grid { grid-template-columns: 1fr; }
.result-grid .result-video { min-height: 240px; }

View File

@ -1,39 +0,0 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import GoogleSignIn from "@/components/google-sign-in";
import { setMe, signIn } from "@/lib/auth";
import { useState } from "react";
function LoginForm() {
const router = useRouter();
// 로그인 전에 가려던 곳. 없으면 홈으로 보낸다
const next = useSearchParams().get("next") || "/";
const [error, setError] = useState<string | null>(null);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center",
gap: "1.5rem", paddingTop: "8rem" }}>
<h1 className="page-title"></h1>
<p className="page-subtitle"> .</p>
<GoogleSignIn onCredential={(credential) => {
setError(null);
signIn(credential)
// 사이드바 배지와 목록이 같은 값을 보므로 여기서 넣어줘야 같이 바뀐다
.then(setMe)
.then(() => router.replace(next))
.catch((failure) => setError((failure as Error).message));
}} />
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)" }}>{error}</p>}
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<p style={{ color: "var(--color-text-gray-400)" }}> </p>}>
<LoginForm />
</Suspense>
);
}

View File

@ -1,45 +0,0 @@
"use client";
/** 사이드바 맨 아래 계정 카드. 누구로 들어와 있는지, 남은 크레딧, 나가는 길 */
import { useRouter } from "next/navigation";
import { signOut, useMe } from "@/lib/auth";
export default function AccountBadge() {
const router = useRouter();
const { me } = useMe();
if (me === null) return null;
if (me === false) {
return (
<div className="account-card">
<button type="button" className="account-link" onClick={() => router.push("/login")}>
</button>
</div>
);
}
return (
<div className="account-card">
{me.picture ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="account-photo" src={me.picture} alt="" referrerPolicy="no-referrer" />
) : (
<span className="account-photo account-photo--blank">
{(me.name || me.email).slice(0, 1)}
</span>
)}
<div className="account-body">
<p className="account-name" title={me.email}>{me.name || me.email}</p>
<p className="account-credit"> : {me.jobs_left}</p>
</div>
{/* 화면 여기저기 남의 계정 데이터가 남아 있어 통째로 새로 받는다 */}
<button type="button" className="account-link"
onClick={() => { signOut().finally(() => { window.location.href = "/"; }); }}>
</button>
</div>
);
}

View File

@ -1,66 +0,0 @@
"use client";
/**
* .
*
* .
* (useRequireLogin).
*/
import { usePathname, useRouter } from "next/navigation";
import GoogleSignIn from "@/components/google-sign-in";
import { signIn, useMe } from "@/lib/auth";
import { useState } from "react";
// 이 주소들은 그대로 보여준다. 하위 경로까지 여는 것은 아카이브뿐이다
const OPEN_PAGES = ["/", "/poster", "/playreel", "/login"];
const OPEN_TREES = ["/archive", "/playreel/archive"];
function isOpen(pathname: string): boolean {
return OPEN_PAGES.includes(pathname)
|| OPEN_TREES.some((open) => pathname === open || pathname.startsWith(`${open}/`));
}
/** 시작 화면이 제출 직전에 부른다. 로그인 안 돼 있으면 로그인 화면으로 보낸다 */
export function useRequireLogin() {
const router = useRouter();
const pathname = usePathname();
const { me } = useMe();
return () => {
if (me === false) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
return false;
}
return true;
};
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { me, setMe } = useMe();
const [error, setError] = useState<string | null>(null);
if (isOpen(pathname)) return <>{children}</>;
if (me === null) {
return <p style={{ color: "var(--color-text-gray-400)" }}> </p>;
}
if (me === false) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center",
gap: "1.5rem", paddingTop: "6rem" }}>
<h1 className="page-title"> </h1>
<p className="page-subtitle"> .</p>
<GoogleSignIn onCredential={(credential) => {
setError(null);
signIn(credential).then(setMe).catch((failure) => setError((failure as Error).message));
}} />
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)" }}>{error}</p>}
</div>
);
}
return <>{children}</>;
}

View File

@ -13,8 +13,8 @@ export const isAutoApprovable = (g: GateKey | null | undefined): g is AutoApprov
!!g && (AUTO_APPROVABLE as readonly string[]).includes(g);
const AUTO_COPY: Record<AutoApprovable, string> = {
fetch_confirm: "상세페이지에서 읽은 정보와 기본 섹션을 그대로 사용",
narration_confirm: "자동 생성 문장과 기본 목소리로 바로 진행",
fetch_confirm: "무료 · 상세페이지에서 읽은 정보와 기본 섹션을 그대로 사용",
narration_confirm: "무료 · 자동 생성 문장과 기본 목소리로 바로 진행",
};
const Lock = () => (
@ -52,7 +52,7 @@ export function AutoApprovePanel() {
<div className="pref-row" style={{ opacity: 0.55 }}>
<div style={{ minWidth: 0 }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 600 }}>2 · 4 · 5</p>
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}> </p>
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}> </p>
</div>
<span style={{ color: "var(--color-text-gray-500)" }}><Lock /></span>
</div>

View File

@ -38,6 +38,7 @@ export function GateShell({ gate, children, onApprove, onBack, busy, error, appr
</div>
<p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>
· {g.next} · {g.eta}
{g.credits > 0 && <> · <strong style={{ color: "#ffd27a" }}>{g.credits}</strong></>}
</p>
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem" }}>{error}</p>}
</div>
@ -151,7 +152,7 @@ export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (ed
setSections(n); emit(n);
}} />
{data.poster_width <= 800 && (
<Warn> {data.poster_width}px로 . 2 . .</Warn>
<Warn> {data.poster_width}px로 . 2 (2). .</Warn>
)}
</div>
<div>
@ -189,7 +190,7 @@ export function AnalysisGate({ data, onChange }: { data: AnalysisReview; onChang
</div>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}> </p>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-300)" }}>Kling 3.0 pro · 8</p>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-300)" }}>Kling 3.0 pro · 8 · 14</p>
{data.ip_risk && <Warn> IP( ) . Kling으로만 . .</Warn>}
{!data.has_qr && <p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}> QR이 QR은 .</p>}
</div>
@ -257,7 +258,7 @@ export function ClipGate({ data }: { data: ClipReview }) {
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>5 </p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.frames_url} alt="프레임 시트" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} />
<Warn> . .</Warn>
<Warn> {data.retry_credits} . .</Warn>
</div>
</div>
);

View File

@ -1,54 +0,0 @@
"use client";
/** 구글이 그려주는 버튼. 우리가 모양을 흉내 내면 브랜드 규정에 걸린다 */
import { useEffect, useRef, useState } from "react";
import {
fetchConfig, loadGoogleIdentity, type GoogleButtonOptions,
} from "@/lib/auth";
export default function GoogleSignIn({ onCredential, text = "signin_with" }: {
onCredential: (credential: string) => void;
text?: GoogleButtonOptions["text"];
}) {
const host = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
// 콜백이 매 렌더마다 바뀌어도 구글에 다시 등록하지 않게 최신 것만 들고 있는다
const latest = useRef(onCredential);
latest.current = onCredential;
useEffect(() => {
let cancelled = false;
(async () => {
const config = await fetchConfig();
if (!config.enabled) throw new Error("구글 로그인이 설정되지 않았습니다");
const google = await loadGoogleIdentity();
if (cancelled || !host.current) return;
google.initialize({
client_id: config.client_id,
callback: (response) => {
if (response.credential) latest.current(response.credential);
},
auto_select: false,
cancel_on_tap_outside: true,
});
google.renderButton(host.current, {
type: "standard", theme: "filled_black", size: "large",
shape: "pill", text, logo_alignment: "center", width: 320,
});
})().catch((failure) => {
if (!cancelled) setError((failure as Error).message);
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
if (error) {
return <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", margin: 0 }}>{error}</p>;
}
return <div ref={host} style={{ display: "flex", justifyContent: "center" }} />;
}

View File

@ -1,142 +0,0 @@
"use client";
/**
* .
* credential , HttpOnly
* .
*
* GIS . .
*/
import { useEffect, useSyncExternalStore } from "react";
import { apiFetch } from "@/lib/api";
const SCRIPT_URL = "https://accounts.google.com/gsi/client?hl=ko";
const SCRIPT_ID = "google-identity-services";
export interface Me {
email: string;
name: string;
picture: string;
jobs_created: number;
job_limit: number;
jobs_left: number;
}
export interface AuthConfig {
enabled: boolean;
client_id: string;
}
type CredentialResponse = { credential?: string };
export type GoogleButtonOptions = {
type?: "standard" | "icon";
theme?: "outline" | "filled_blue" | "filled_black";
size?: "small" | "medium" | "large";
shape?: "rectangular" | "pill" | "circle" | "square";
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
width?: number;
logo_alignment?: "left" | "center";
};
type GoogleIdApi = {
initialize(config: {
client_id: string;
callback: (response: CredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
}): void;
renderButton(parent: HTMLElement, options: GoogleButtonOptions): void;
disableAutoSelect(): void;
};
declare global {
interface Window {
google?: { accounts?: { id?: GoogleIdApi } };
}
}
// 스크립트는 한 번만 받는다
let loading: Promise<GoogleIdApi> | null = null;
export function loadGoogleIdentity(): Promise<GoogleIdApi> {
const ready = window.google?.accounts?.id;
if (ready) return Promise.resolve(ready);
if (loading) return loading;
loading = new Promise<GoogleIdApi>((resolve, reject) => {
const done = () => {
const api = window.google?.accounts?.id;
if (api) resolve(api);
else reject(new Error("구글 스크립트에 accounts.id 가 없습니다"));
};
const fail = () => {
// 다음 시도에서 다시 받을 수 있게 비운다 — 광고 차단기나 사내망에서 한 번 막히는 일이 흔하다
loading = null;
reject(new Error("구글 로그인 스크립트를 받지 못했습니다"));
};
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener("load", done, { once: true });
existing.addEventListener("error", fail, { once: true });
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = SCRIPT_URL;
script.async = true;
script.defer = true;
script.addEventListener("load", done, { once: true });
script.addEventListener("error", fail, { once: true });
document.head.appendChild(script);
});
return loading;
}
export const fetchConfig = () => apiFetch<AuthConfig>("/api/auth/config");
export const signIn = (credential: string) =>
apiFetch<Me>("/api/auth/google", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential }),
});
export const signOut = () => apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
// 로그인 정보는 화면 여러 곳이 같이 본다 — 사이드바 배지와 페이지가 따로 들고 있으면
// 한쪽에서 갱신해도 다른 쪽이 옛 값을 그린다. 한 군데 두고 나눠 쓴다
let cached: Me | null | false = null;
const listeners = new Set<() => void>();
function publish(next: Me | null | false) {
cached = next;
listeners.forEach((notify) => notify());
}
function subscribe(notify: () => void) {
listeners.add(notify);
return () => { listeners.delete(notify); };
}
/** 서버에 다시 물어본다. 잡을 만든 뒤처럼 남은 개수가 바뀌는 자리에서 부른다 */
export function refreshMe(): Promise<void> {
return apiFetch<Me>("/api/auth/me")
.then((me) => publish(me))
.catch(() => publish(false));
}
export const setMe = publish;
/** 로그인 여부. null 이면 아직 확인 중, false 면 로그아웃 상태 */
export function useMe() {
const me = useSyncExternalStore(subscribe, () => cached, () => null);
useEffect(() => {
if (cached === null) refreshMe();
}, []);
return { me, refresh: refreshMe, setMe: publish };
}

View File

@ -3,11 +3,9 @@
* API가
* .
*
* styling /studio /api/f2
* playreelEntry .
* styling /studio /api/f2
*/
export const FEATURES = {
styling: false,
archive: true,
playreelEntry: false,
} as const;

View File

@ -110,34 +110,34 @@ export function parseGoodsId(url: string): string | null {
return m ? m[1] : null;
}
// ── 게이트 메타 (카드 헤더 문구) ───────────────────────────────────
// ── 게이트 메타 (카드 헤더·비용 문구) ───────────────────────────────
export const GATE_META: Record<GateKey, {
step: number; name: string; title: string; why: string; next: string; eta: string; canBack: boolean;
step: number; name: string; title: string; why: string; next: string; credits: number; eta: string; canBack: boolean;
}> = {
fetch_confirm: {
step: 1, name: "수집 확인", title: "이 공연이 맞는지, 어떤 부분을 넣을지 확인해 주세요",
why: "상세페이지에서 가져온 정보로 영상의 뼈대를 만듭니다. 캐스팅 스케줄은 항상 들어갑니다.",
next: "포스터 화질 보정 · 요소 분석", eta: "약 2분", canBack: false,
next: "포스터 화질 보정 · 요소 분석", credits: 2, eta: "약 2분", canBack: false,
},
analysis_confirm: {
step: 2, name: "연출 확인", title: "포스터에서 무엇을 움직일지 정해주세요",
why: "승인하면 영상 생성이 시작되고 되돌릴 수 없습니다. 제목·로고·인물은 원본 그대로 고정됩니다.",
next: "Kling 3.0 영상 생성", eta: "약 5~8분", canBack: true,
next: "Kling 3.0 영상 생성", credits: 14, eta: "약 5~8분", canBack: true,
},
narration_confirm: {
step: 3, name: "나레이션 확인", title: "나레이션 문장과 목소리를 확인해 주세요",
why: "이 문장이 그대로 읽힙니다. 캐스팅 스케줄 안내와 일시·CTA 문장은 꼭 필요합니다.",
next: "음성 합성 · 배경음악 생성", eta: "약 3분", canBack: true,
next: "음성 합성 · 배경음악 생성", credits: 0, eta: "약 3분", canBack: true,
},
clip_confirm: {
step: 4, name: "클립 검수", title: "생성된 장면을 확인해 주세요",
why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 처음부터 다시 만듭니다.",
next: "원본 글자 합성 · 상세페이지 스크롤 조립", eta: "약 5분", canBack: false,
why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 크레딧이 다시 듭니다.",
next: "원본 글자 합성 · 상세페이지 스크롤 조립", credits: 0, eta: "약 5분", canBack: false,
},
final_confirm: {
step: 5, name: "최종 검수", title: "완성된 예고편을 확인해 주세요",
why: "승인하면 이 버전이 고정되어 아카이브에 저장됩니다. 이후 수정은 새 버전으로 만들어집니다.",
next: "아카이브 저장 · 다운로드", eta: "즉시", canBack: false,
next: "아카이브 저장 · 다운로드", credits: 0, eta: "즉시", canBack: false,
},
};

View File

@ -3,7 +3,7 @@
/**
* .
* `playreel.autoApprove` = { fetch_confirm?: true, narration_confirm?: true }
* (·). ·· .
* (·). ·· GATE_META.credits/ .
* ( ). `approve` /playreel/[id] .
*/